Skip to content

Release v0.1.0a2 (alpha 2) - #2

Merged
begeistert merged 158 commits into
mainfrom
release/v0.1.0a2
Jun 19, 2026
Merged

Release v0.1.0a2 (alpha 2)#2
begeistert merged 158 commits into
mainfrom
release/v0.1.0a2

Conversation

@begeistert

Copy link
Copy Markdown
Contributor

PyMCU AVR backend v0.1.0a2 — second alpha. Code generation, the AVR math/exception runtime, and the integration test suite all advance significantly. 158 commits since v0.1.0a1.post2.

Calling convention & registers

  • Functions with more than five arguments: overflow arguments are passed through a fixed SRAM spill region (spilled args stored before register args so a call-result argument survives).
  • 32-bit sign extension fixed in all four LoadIntoReg operand layouts (int16int32 no longer reads garbage); zero-extension loads the source's real high byte.
  • Size-based argument register assignment for wide non-first arguments; correct liveness for IndirectCall/GcAlloc/array/bytearray ops.

Exception runtime (T-flag model)

  • Unhandled-exception runtime emitted for propagated uncaught errors (loud halt instead of silent garbage); a locally-caught raise lowers to a plain jump (no SET/RET).
  • The optimizer keeps catch blocks reachable through SignalError edges and counts SignalError.Code as a use (no dangling labels / undefined references).

Math & codegen

  • __mul32 for 32-bit multiply (was truncating to 8 bits); signed floor-div/mod runtime routines; arithmetic right shift sign-extends on whole-byte shifts.
  • ISR-shared globals promoted to GPIOR registers; flash-string-by-reference (FlashStrAddr + FlashLoadPtr).
  • CALL/JMP + -mrelax linking fixes far-target relocations; ISR context-save now covers R20–R23 and the Z pointer; out-of-range @interrupt vectors are rejected, not dropped.
  • Removed stray R16/R17 scratch clobbers in array/exception codegen; inline outlining only shares byte-identical regions; deep-inheritance call-passthrough regions stay inline so returns are correct.

Testing

The integration suite (compile → simulate on an ATmega328P, check real firmware behavior) now stands at 1021 tests, covering the new arithmetic promotion model, f-strings/format-specs, the full exception model, multi-argument calls, and many fidelity regressions.

🤖 Generated with Claude Code

begeistert and others added 30 commits June 8, 2026 13:58
The existing STD/LDD forwarding only fires on adjacent store/reload pairs.
16-bit values interleave their lo/hi halves (STD lo; STD hi; LDD lo; LDD hi)
and call results are parked in a slot then reloaded for a compare, so the
redundant reload is never adjacent to its store and survives.

ForwardStores tracks, per basic block, which register still mirrors each Y+off
slot and drops reloads that re-read a value the register already holds (and
re-stores of an unchanged value). It is conservative by construction: STD/LDD
are modeled precisely, a small set of pure readers leave tracking intact, and
anything else (multi-register writers, pointer loads, calls, branches, labels,
unknown mnemonics) clears all tracking -- so it never forwards a stale value
across an unmodeled effect or a basic-block boundary.

Cross-block churn (store/reload around a join label) is left untouched on
purpose: at a join the slot value is consistent but the holding register is
not, so forwarding there would be wrong. That redundancy is an IR-level
copy-propagation concern, not a peephole one.

Updated Float_Return_LoadsIntoR22R25: the single-Copy case now correctly elides
its redundant reload (the value is already in R22:R25), so the test forces a
genuine reload with an intervening float Copy that clobbers all four bytes.

All 81 unit tests and 722 integration (simulator) tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eference)

Backend half of passing a const[str] to a non-@inline function by reference:

- LoadIntoReg(FlashStrAddr): LDI lo8/hi8(__flash_<name>) -- the string's 16-bit
  flash byte-address (byte address, no gs() word scaling; read via LPM).
- CompileFlashLoadPtr: Dst = flash[Ptr + Index] via LPM, with the base held in a
  register pair (Z) instead of a fixed label (mirrors CompileArrayLoadFlash).
- AvrLinearScan visits the new operands so temp liveness stays correct, and the
  dead-parameter-store check counts the Ptr/Index reads.

Lets uart_write_str compile to one shared subroutine; dht-sensor 1362 -> 1108 B.
All 81 unit + 722 integration (simulator) tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A call result is parked in its temp register's home (MOV R16/R17, R24) but the
comparison that follows consumes it straight from R24, so the home-store is dead.
A peephole can't see this -- the value's death is only provable across the branch
that follows.

EliminateDeadTempMoves runs a backward liveness restricted to the two linear-scan
temporaries and drops a MOV whose destination is never read before being
overwritten or before the function returns. Restricting to R16/R17 makes the exit
condition exact: they are scratch (never a return register; if ever pushed, the
matching POP kills the value first), so they are dead at every RET.

Safety is asymmetric by construction: reads are over-approximated (unknown
mnemonics and inline-asm Raw that textually mentions the register keep it live;
assembler directives and register-free Raw like NOP delays stay transparent) while
writes are under-approximated (only an unambiguous pure write kills liveness).
Calls do not read the temps -- PyMCU passes arguments in R24/R22/R20/R18 and
callees that use a temp internally push/pop it. The pass bails on computed jumps.

dht-sensor 1092 -> 1074 B (eight dead home-stores removed). 81 unit + 722
integration (simulator) tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CompileBytearrayLoad/Store used R16/R17 as scratch when adding a variable (or
large-constant) index to Z. Those are exactly the registers the linear-scan
allocator hands out, so a register-allocated value live across a buf[i] access
was silently destroyed -- a latent fragility today and the concrete blocker that
broke UART readinto/readline when register allocation was extended to locals.

Use R26 (X-low, never in the allocation pool) for the index and R1 (the zero
register) for the carry, mirroring CompileArrayLoadFlash. Behaviour-preserving for
current code (R16/R17 are not yet allocated across these sites) and it drops the
redundant CLR. First prerequisite step toward A17 register allocation: codegen
scratch must be disjoint from the allocator's register pool.

81 unit + 722 integration (simulator) tests pass; dht-sensor unchanged at 1074 B.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n codegen

Continues the A17 prerequisite (codegen scratch must be disjoint from the linear-
scan allocator pool). Audited every hardcoded R16/R17 use:

  - ArrayLoad / ArrayStore variable-index: used CLR R16 + ADC R31,R16 as the carry
    zero -> now ADC R31,R1 (the zero register), dropping the CLR and the clobber.
  - TryBegin/longjmp 16-bit handler test: MOV R16; OR R16 -> R26 (X-low scratch).

Found safe (no change needed): ISR context save/restore (PUSH/POP R16/R17), the
main stack-pointer init (runs before any interval), and the MUL/MULSU paths --
those operate on R18-R25, never R16/R17, so signed multiply does not conflict.

Documented as allocator constraints (not codegen-fixable): inline-asm operand
loading uses R16-R19 by its %N convention, and GcRoot/GcUnroot use R16/R17 as
shadow-stack scratch (GC-only, and emitted at prologue / before returns where no
1-byte local is live). A future allocator must avoid R16/R17 across those.

Behaviour-preserving for current code. 81 unit + 722 integration tests pass;
dht-sensor unchanged at 1074 B. Validated with a throwaway binary; build/bin not
modified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The global register allocator excluded every name with DotCount >= 2 (the
inline-expanded ZCA/HAL locals) from R4-R15. For call-heavy code like the DHT
hot loop those locals — including the highest-use values (_read_byte.result,
pin_pulse_in.result, ...) — were the dominant SRAM traffic.

The DotCount filter was a heuristic, not a correctness requirement: R4-R15 are
never used as codegen scratch and this allocator assigns a globally-unique
register per name, so a register-resident value survives any call (the callee's
own named vars get different registers; leaf scratch is R16-R27/R30/R31). That
invariant makes dotted inline locals just as safe to registerize.

Drops the exclusion and adds explicit safety filters for the names that some
codegen path resolves by SRAM address instead of through _regLayout:
GcRoot/GcUnroot targets and bytearray/array base names, plus GC_REF/FUNCREF
pointer types. Deterministic tie-break (ordinal) keeps output stable.

Net effect across examples: lm35 -10, pwm -6, adc -4, dht -2 B, 0 regressions.
This converts ~40 LDD/STD into register-register MOVs in the DHT loop, which is
size-neutral on AVR (both are 1 word) but cuts cycles and SRAM, and is the
prerequisite for operand coalescing. 722 integration + 81 unit green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CompileBinary always staged the second operand through R18 before the reg-reg
op (ADD/SUB/AND/OR/EOR), e.g. `MOV R18,Rhome; ADD R24,R18`. When the operand
already lives in a home register of the matching width, use that register pair
directly (`ADD R24,Rhome`), dropping one MOV per op.

Guarded to stay correct: only the reg-reg ops above (variable shifts clobber
R18; div/mod/mul use fixed ABI registers), only when src2's width equals the
op width (no zero/sign extension needed), and never for 32-bit (no register
homes from the allocator). Reading a register as the second ALU operand never
clobbers it, so both the named-var pool (R4-R15) and the temporary pool
(R16/R17) are safe sources.

Small win on its own (the DHT's binary ops are 31/34 immediate-src2), but it is
the first step of the larger in-place/operand-coalescing work needed to close
the instruction-count gap vs gcc -Os. 722 integration + 81 unit green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The template codegen compiled every Binary as "stage src1 into R24, operate,
store R24 to dst home" — a fixed MOV/op/MOV round-trip. For the augmented form
`x = x OP c|y` where x lives in a home register, that round-trip is pure
overhead: the op can run directly on the home register.

Adds TryCompileBinaryInPlace, a fast path that emits the operation straight into
dst's home register and returns (no R24 stage, no store-back). Restricted to the
cases where it is an unambiguous win and AVR register classes allow it:
  - 8-bit Add/Sub/And/Or/Xor, dst == src1 (src1 already resident in the home reg);
  - const +/-1 -> INC/DEC (any register);
  - other immediates -> SUBI/ANDI/ORI only when the home is R16-R31;
  - register/SRAM src2 -> OP rd, <reg> (src2 staged through R18 only if in SRAM).
Everything else (dst != src1, 16/32-bit, comparisons, mul/div/mod, variable
shifts, float, constant src1) falls back to the existing staged path unchanged.
Validation is up front so a fallback emits nothing.

This is the first instruction-selection step toward closing the ~2.6x
instruction-count gap vs gcc -Os (which keeps values in registers and operates
in place). Measured vs the pre-A17 baseline, no regressions: dht 1074->1068,
pwm 434->424, lm35 2122->2112, adc 658->654. 722 integration + 81 unit green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generalizes TryCompileBinaryInPlace to UINT16/INT16: `x = x OP y` with a 16-bit
home register pair emits byte-wise in place (ADD/ADC, SUB/SBC, AND/OR/EOR on
rd:rd+1) instead of staging through R24:R25 and storing back. 16-bit constant
operands still fall back — they need SUBI/SBCI on an R16-R31 pair and the 16-bit
homes are always R4-R15 (the temporary pool is 8-bit).

No delta on the current examples (their hot 16-bit ops aren't the augmented
form), but it completes the optimization rather than leaving it artificially
8-bit-only, and applies to 16-bit-heavy code. 722 integration + 81 unit green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oline)

EmitBranch lowers every "branch to target if cond" as a trampoline:
    inv(cond) skip
    RJMP      target
  skip:
so the RJMP can reach any distance — conditional branches only have ±64 words
of reach. But the vast majority of branches in compiled control flow are short,
so the trampoline is pure overhead.

Adds a ShortenBranches peephole that collapses the triple back to a single
`cond target` whenever target is within the conditional branch's reach. The
displacement is measured locally in words (CALL/JMP/LDS/STS = 2, the rest = 1);
a candidate whose span contains a Raw line or directive — anything whose size or
effect on the address counter we can't account for — is left alone. The
fixed-point loop is monotone: shortening only ever brings other branches closer,
so a candidate validated in the current layout stays in range after every later
removal. A mis-estimated displacement would be rejected by the assembler (a
loud, test-caught build failure), never silently mis-branch — and the simulator
integration tests exercise the inversions end to end.

Big, fully general win (every if/loop): dht 1068->1010, lm35 2112->2022,
eeprom 486->416, adc 658->600, uart-echo 174->146, pwm 424->398. 722
integration + 81 unit green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ShortenBranches bailed on any candidate whose span contained a Raw line (an
inline-asm body is emitted as one multi-line string), because their flash size
was unknown — leaving branches that jump across HAL asm (delay loops, pulse
loops) on the slow RJMP-trampoline form.

TryRawWordSize counts a Raw block's instruction words: comments and labels are
free, recognized zero-size directives (.equ/.global/...) are skipped, and
anything that emits data or moves the origin (.org/.byte/.word/unknown) still
bails the candidate. This lets the branch-shortening pass reach across inline
asm. Clean-build .hex: wdt-blink 292->274, dht 1116->1114.

(Investigated CALL->RCALL shortening too; it is a no-op here because the GNU
as/ld toolchain already performs call relaxation with -mrelax, so it was not
added.) 722 integration + 81 unit green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds Ssd1306InitTests: boots the ssd1306 example with a recording I2C device
at 0x3C and asserts the bytes the driver emits during oled.init(). The init
now comes from a flash const[uint8[25]] table walked by a runtime loop, so
these lock down its observable output:
  - exactly 25 commands (25 control+command pairs on the wire),
  - every control byte is 0x00 (command stream),
  - the command bytes equal the datasheet sequence, in order.

Guards the table/loop against silent drift. 725 integration tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The @inline driver composition emits split constant masks like
(nib & 0xF0) | _RS | _BL -> ORI Rd,1; ORI Rd,8. Folding the immediates is a
flat win: Rd = (Rd op a) op b == Rd op (a op b); the second op alone fixes Rd
and SREG and nothing significant reads the intermediate, so flag behaviour is
preserved.

Runs after EliminateDeadTempMoves so the staging MOV the template codegen
parks between the two ops (MOV R16,R24) is already gone, leaving them
consecutive among significant lines (comments/debug markers between are
skipped; a label or raw-asm between bails).

lcd-i2c example 746 -> 738 B. 725 AVR integration green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After `MOV d,s` both registers hold the same value, but the main alias pass
clears its facts at every conditional branch (a join-point precaution), so a
reload `MOV s,d` the codegen emits after a compare+branch survives even though
the register is unchanged on the fall-through — e.g. the decimal printer's
`MOV R9,R24; CPI R24,..; BRcc ..; MOV R24,R9`.

Adds EliminateRedundantReloads: from a `MOV d,s`, scan forward removing any
copy between {d,s} until an instruction writes d or s, or a label (a possible
alternate entry) intervenes. WritesReg over-approximates writes (calls clobber
scratch but preserve R4-R15; unrecognised mnemonics count as writes) so the
removal is always sound. Runs after ShortenBranches, which collapses the
`BRcc skip; RJMP t; skip:` lowering back to a single `BRcc t` and removes the
skip label that would otherwise stop the scan.

sensor-dashboard 1374->1350, bmp280 1192->1176, dht 992->988, ssd1306 520->514,
lcd-i2c 738->734. 725 AVR integration green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The template codegen parks a computed value in a home register when R24 is
needed for another argument, then reloads it:
    MOV Rh, Rs ; <set up other args, clobbering Rs> ; MOV Rd, Rh ; CALL
EliminateParkRoundTrip rewrites the park to target Rd directly and drops the
reload (MOV Rd, Rs ; <args> ; CALL), when between the two MOVs nothing reads or
writes Rh or Rd and there is no call/branch/label, and Rh is dead afterward.

Soundness rests on two conservative predicates: WritesReg/ReadsReg
over-approximate effects (calls touch only the arg/scratch registers, anything
unrecognised is assumed to read and write), and RegDeadAfter bails at ANY path
divergence (branch/jump/label/RET) -- a write seen past a conditional branch
does not redefine the register on the not-taken path, which is exactly the bug
an earlier version hit on the array-min loop (`min = x` guarded by BRSH).

lcd-i2c 734->730, bmp280 1176->1172, ssd1306 514->512. 725 AVR integration green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Large programs (and FFI calls to distant C symbols) overflow the 13-bit
PC-relative range of RCALL/RJMP (±2047 words ≈ ±4 KB): the linker reports
"relocation truncated to fit: R_AVR_13_PCREL". The asm filter already upgraded
RCALL->CALL; do the same for RJMP->JMP (full 22-bit absolute range) and pass
-mrelax to the linker so it relaxes each CALL/JMP back to RCALL/RJMP wherever
the target is in range. Small programs keep the compact encoding; large ones
link. The atmega vector table (.org 4-byte slots) is filled exactly by a 4-byte
JMP, and relaxes back to RJMP+pad when in range.

Side benefit: calls that previously stayed CALL (no relaxation was enabled) now
shrink to RCALL where possible -- dht 988->936, lcd-i2c 730->662, bmp280
1172->1130, ssd1306 512->492. A 28 KB stress program that previously failed to
link now builds. 725 AVR integration green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ports File > Examples > 03.Analog > Smoothing: a 10-sample running average of
A0 printed over UART. SmoothingTests drives the ADC with a constant input and
asserts the average converges to it -- using values >255 (500, 300) so the test
also pins the uint16 print path (an 8-bit truncation would print 244 / 44).

727 AVR integration green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two accesses to the same array with the same variable index in one straight-line
region each lower to a 6-instruction recipe that rebuilds base+index*scale into Z,
then load/store through Z (which leaves Z intact). The second rebuild is dead work:
Z already holds the address. EliminateRedundantZSetup tracks the live Z recipe and
drops an identical rebuild when the index source and Z are provably untouched in
between, nulling the fact at any control-flow boundary (label/jump/branch/RET/CALL)
with the same path-divergence discipline as RegDeadAfter.

Sliding-window/accumulator code is the canonical beneficiary: the smoothing example
(readings[index] read-modify-write) drops 644->632 bytes (one Z recipe). 727 AVR
integration tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
__div16 already produces both quotient (R24:R25) and remainder (R26:R27) in one
pass; __mod16 just wraps it. When a basic block divides AND mods the same
dividend by the same divisor -- the decimal-print digit loop's
r = v % 10; v = v // 10 -- the pair was lowered to two full 16-iteration division
calls. DetectDivModFusions matches the pair (same Src1/Src2, unsigned 16-bit, the
second op's destination untouched between, no control flow) and EmitFusedDivMod
emits a single __div16, storing the quotient and remainder from the one call.

Halves the per-digit division work in every multi-digit print and shrinks the
loop body: smoothing 632 -> 626 bytes. Backend-only; the IR pair is left generic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The linear scan only ever register-allocated UINT8 temporaries, so every uint16
temporary spilled to a stack slot -- each one costing a store at its definition
and a reload at every use (the bulk of the STD/LDD traffic in compute-heavy
code). The codegen already drives a register-homed temp's high byte via
GetHighReg, so a pair-homed 16-bit temp needs no codegen change; the scan just
never handed one out.

Treat R16/R17 as two byte-slots: an 8-bit temp takes one, a 16-bit temp takes
the pair (low in R16). Call-spanning temps stay excluded. smoothing 626->606,
bmp280 1130->1122, sensor-dashboard 1316->1310, stopwatch 658->654; drivers
unchanged. 727 AVR integration tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
divmod(3000, 10) -> (300, 0): a quotient above 255 that a regression to the old
uint8/__div8 lowering would narrow to 44. Locks in the 16-bit divmod fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reconstructing a 16-bit value from two bytes -- result = (hi << 8) | lo, or + lo
-- is just "low byte = lo, high byte = hi", but the generic widen + 16-bit shift
+ 16-bit add lowering spent ~10 instructions on it. Every 16-bit sensor read
(ADC ADCH/ADCL, BMP280, ...) pays this in its hot path.

DetectBytePackFusions matches a 16-bit `hi << 8` whose result feeds an adjacent
commutative Add/BitOr against a byte operand (so lo lands entirely in the low
byte), with the shift result single-use and hi/lo untouched and no control flow
between. EmitBytePack then loads hi into R25 and lo into R24 and stores -- three
instructions instead of ten. Backend-only; the IR stays generic.

smoothing 600->588, bmp280 1122->1094, sensor-dashboard 1310->1298. 730 AVR
integration + 351 frontend tests green (the sensor tests check real 16-bit
sample values, exercising the reconstruction).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… once

Four ADC channels (four (hi<<8)|lo byte-packs), four sliding-window accumulators
(variable-index array Z-CSE + heavy uint16 temp pressure), the divmod() builtin,
and decimal printing (divmod fusion) in one hot loop. With all channels held at
ADC count 500 the averages converge to 500 and divmod(500,10) yields (50,0) --
proving the combined optimizations preserve the arithmetic under register
pressure. Output is bit-identical across rebuilds (deterministic).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Read A0, map 0-1023 to a 0-255 PWM duty on D6 (OC0A) via the native pymcu HAL
(UART + AnalogPin + PWM), echo the duty byte. Functional test injects ADC count
512 and asserts OCR0A == 512 >> 2 == 128, exercising the ADC byte-pack and the
PWM path end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
machine.ADC + PWM + UART: read A0, map to a PWM duty on D6, echo it. Now builds
correctly after the inline-param stale-binding fix (ADC(Pin(14)) before
PWM(Pin("PD6")) no longer leaks the int pin id into the PWM pin). Functional test
asserts OCR0A == 512 >> 2 == 128 with A0 at ADC count 512.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_seed: uint16 = 3007 prints 3007 (seeded, not 0), then 3008 after a runtime
increment in a callee (proving it is a mutable RAM cell, not a folded constant).
Locks in the global-init and cross-call constant-invalidation fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Exercises comma-separated and aliased module imports plus aliased constructor
(m.UART/m.Pin), member (m.Pin.OUT) and method (t.sleep_ms) resolution. Reaching
the 'MA' UART banner proves they all resolve to the real module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Single-byte globals flagged as isrSharedGlobals in the .mir are rewritten
Variable -> MemoryAddress(GPIORn) before any allocation pass, so the
existing codegen emits IN/OUT (1 cycle, always volatile) instead of
LDS/STS, and SBI/CBI/SBIS/SBIC for bit ops on GPIOR0. The most-used
global gets GPIOR0, the bit-addressable register. BSS shrinks by the
promoted bytes.

Safety: UINT8 only (1-byte MemoryAddress is typed UINT8 by GetValType,
so INT8 would lose signedness in compares); globals used in inline asm,
VirtualCall or GC instructions are excluded; GPIOR addresses already
referenced explicitly by the program are never reassigned; chips outside
the megaAVR/tinyAVR table get no promotion (SRAM is always correct).

The classic ISR<->main flag now compiles from a plain global to the same
code an expert writes by hand with the manual GPIOR0[n] idiom.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tion

Two plain uint8 globals shared between the INT0 ISR and main (flag:
set/poll/clear; presses: ISR-incremented lifetime counter) with no
GPIOR idiom and no manual volatile handling. Verifies behavior in the
simulator (each press delivered), that both globals physically live in
GPIOR0/GPIOR1 (peeking uno.Data[0x3E/0x4A]), and that the backend emits
the promotion markers in the debug asm (via new PymcuCompiler.FixtureDir
helper).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ain globals

All 12 interrupt examples now signal between ISR and main through plain
uint8 module globals. The compiler detects them as ISR-shared (volatile
semantics) and auto-promotes them to GPIOR0/1/2, producing the same
SBI/CBI/IN/OUT code the manual idiom wrote by hand — with zero SRAM and
no chip-specific imports in user code. READMEs updated to describe the
plain-global pattern.

Migrated: adc-interrupt, i2c-irq, interrupt-counter, pcint-counter,
pin-irq, sensor-dashboard, sleep-wakeup, soft-pwm, spi-irq, stopwatch,
timer-ctc, timer-interrupt.

Side fix: i2c-irq and spi-irq stored the received byte with bit-indexing
(GPIOR0[0] = SPDR.value), which truncated it to bit 0 — a plain uint8
global now carries the full byte as the READMEs always documented.

All 12 rebuilt and promotion-verified; integration suite 748/748 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
begeistert and others added 28 commits June 17, 2026 01:32
print(f"...") with runtime interpolations: mixed uint8/uint16 widths,
a negative int16 and a float, and a constant-string (const[str]) part.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
uart.write_str(f"a={s} b={n};") and uart.println(f"line={s}") with runtime
uint8/uint16 interpolations, including the println newline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
int16(-5) < uint16(200) must be True (Python compares by value); guards
against a C-style promotion to unsigned that would compare 65531 < 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign extension was only applied for size==2 (int8->int16); widening a signed
value to 32 bits (int8->int32, int16->int32) fell through both the sign- and
zero-extension paths and left the upper bytes as garbage, so int32(int16(-8))
read e.g. 0x0008FFF8 instead of -8. LoadIntoReg now loads only the source's
real bytes in all four operand layouts and fills the remaining high bytes via
a shared EmitWidenFill: zero for unsigned, the sign of the source's highest
real byte for signed, at any target width.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cover hex/HEX/binary/padded-decimal format specs (FStringFormatSpecs),
signed decimal padding and octal (FStringFormatSignedAndOctal), and the
int16->int32 sign-extension regression (SignedWidenToInt32).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The caller and callee argument loops were hard-capped at 4 (`k < 4`), so a
non-inline function with 5+ parameters silently dropped args 5+ — the callee
read uninitialised registers (e.g. f(a,b,c,d,e) lost e). Both loops now load
and read every argument that ArgBaseRegs assigns (the virtual-call path
already did). Argument registers must be R16..R25 (loading an immediate
needs a high register), so ArgBaseRegs now rejects an assignment below R16
with a clear "too many/wide arguments" error pointing at @inline / a struct,
instead of emitting an assembler-rejected low-register access. Five small
args (or fewer wide ones) now pass correctly; more is a clean error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
FiveArgsAllArrive verifies the 5th argument now arrives (the cap-at-4 silent
drop). TooManyRegisterArgsRejected checks that an over-budget argument list
is rejected with a clear message rather than miscompiled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build and run an LCD program using lcd.print_str(f"T={s} 0x{n:04x}") with a
runtime value and a hex format spec; assert it reaches its UART banner (the
LCD format/decompose path is valid and executes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
rev_sum() builds and reads a local uint8[8] with runtime indices and is
@inline-expanded into the caller; it must compute 20 for n=5 rather than
failing to compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Functions with more arguments than fit in R16..R25 (about five 1-2 byte
args) previously errored. They now pass the overflow arguments through a
fixed SRAM region `_arg_spill` instead of reaching into R8..R15 — which is
the register allocator's R2..R15 home pool, whose cross-call safety relies
on the calling convention never touching it. The caller stores each spilled
arg (staged through R26:R27) and the callee loads it into its home/slot in
the prologue, before any nested call, so a single shared region is safe
(consistent with PyMCU's existing static-frame, non-reentrant model). The
spill region is sized to the largest overflow across the program and placed
just above the static frame. Register args (<= 5) are unchanged. A spilled
argument wider than 2 bytes is still rejected with a clear message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SixArgsViaSpill (6 uint8: one spilled), EightArgsViaSpill (8 uint8: three
spilled), and SpilledArgsWith16BitMix (a uint16 spilled at a 2-byte offset)
each verify every argument arrives intact past the R16..R25 register budget.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A spilled argument whose value was a temp (e.g. a nested call result) read 0:
the register-arg loads ran first and overwrote the temp's home (R16..R25)
before the spill store read it. CompileCall now stores all spilled arguments
first — reading their sources while still intact — then loads the register
arguments. Staging stays in R26:R27, which the temp pool (R16:R17 only) and
home pool (R2..R15) never use, so no argument source can alias it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
f6(1,1,1,1,1, add1(s)) must return 65 — the spilled 6th argument is a call
result that must survive into the spill region (regression for arg ordering).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DefaultArgViaSpill (a default value fills a spilled parameter),
MethodSelfPlusFiveArgsViaSpill (self + 5 args = 6, the last spills), and
ConsecutiveSpillCalls (two calls reuse the shared spill region independently).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ation

BitNotUint16Width (~uint16 respects the 16-bit width -> 65530),
Uint32RuntimeDivMod (uint32 % and // by a runtime divisor), and
GlobalArrayMutationPersists (a module-level array mutated across function
calls keeps its values).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AugAssignInstanceField (self._n += v through methods) and SliceWithStep
(a[0:6:2] selects indices 0,2,4) — both behave faithfully.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RuntimeDivByZeroRaises (100 // 0 caught by except ZeroDivisionError -> 77)
and RuntimeDivByNonZeroWorks (100 // 5 -> 20, no raise).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`try: r = 100 // s; except ZeroDivisionError: r = 88` (no Call in the try
body) must build and catch -> 88. Regression guard for the dangling catch
label / link failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ht errors

The exception runtime was emitted only when a Call to __pymcu_unhandled_exn
existed (an unmatched try). Uncaught-error propagation now reaches it via a
BranchOnError too, so detect that target as well. Test UncaughtExceptionHalts:
an uncaught runtime divide-by-zero halts the device (the code after the
faulting division never runs) instead of silently continuing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…se-from-callee

ExceptionPropagatesThroughLevels (c->bb->run, caught at top),
WrongTypeExceptionPropagatesToOuter (inner except mismatch re-propagates to the
outer try), FinallyRunsOnErrorPath (except then finally), and
RaiseFromCalleeCaught (explicit raise in a called function is caught).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TryElseRunsWhenNoException (else runs on success), TryElseSkippedOnException
(else skipped when an exception fired), ReraiseInExceptPropagates (raise inside
an except handler propagates to the caller's try), and SecondExceptClauseMatches
(dispatch falls through to the matching handler).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
FinallyRunsBeforePropagation (finally runs before the error propagates to the
caller's handler), FinallyRunsBeforeReturn (return in a try runs finally first),
and InlineRaisePropagatesToCaller (a raise inside an @inline function is caught
by the caller's try).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
FinallyRunsBeforeBreak (break in a try-with-finally runs finally -> 31) and
FinallyRunsBeforeContinue (continue runs finally each iteration -> 58).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ErrorCodePreservedThroughFinally (R22 survives a finally that runs a uart
routine, so the right handler matches), RaiseInElsePropagates (raise in else is
not caught by its own try), RaiseInFinallyOverrides (a finally raise propagates),
RecoverAndContinueAfterTry (code after a caught try runs), TryInLoopRecovers-
EachIteration (T-flag clean per iteration), and SequentialTryBlocks (T reset
between two try blocks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ReturnInExceptHandlerRunsFinally (return inside a handler runs finally -> 8,5)
and RaiseFromMethodCaught (an error raised in a method is caught by the caller).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…override

NestedFinallyOnReturn/OnBreak (two finally levels run innermost-first),
RaiseInConstructorCaught (raise in __init__ is caught), BareReraise (bare
`raise` re-raises to the caller), and FinallyExceptionOverridesPending (a
finally that raises replaces the pending exception).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s R22

The handler runs a 2-arg call (2nd arg in R22) before a bare raise; the
re-raised exception must still be ZeroDivisionError, caught by the caller.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ModuloByZeroRaises (% by zero raises like //), NestedBareReraise (bare raise
through two handler levels), BreakInFinallySwallowsException and
ReturnInFinallySwallowsException (break/return in finally discard the in-flight
exception, per Python), and HandlerRaisesNewExceptionCaughtByOuter (a new
exception from a handler propagates to the outer try).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@begeistert
begeistert merged commit 4caefe3 into main Jun 19, 2026
7 of 9 checks passed
@begeistert
begeistert deleted the release/v0.1.0a2 branch June 19, 2026 05:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant