diff --git a/examples/adc-interrupt/README.md b/examples/adc-interrupt/README.md index a554417d..691df1fd 100644 --- a/examples/adc-interrupt/README.md +++ b/examples/adc-interrupt/README.md @@ -9,9 +9,11 @@ instead of polling. `adc.irq(adc_isr)` registers the handler at the ADC vector, enables `ADIE`, and sets the global interrupt flag (`SEI`) for you — no `@interrupt` decorator or `asm("SEI")` needed. -The ISR reads `ADCL` first (which latches `ADCH`), stashes the low byte in -`GPIOR1`, and signals the main loop through bit `GPIOR0[1]`. The main loop prints -the result over UART and kicks off the next conversion. +The ISR reads `ADCL` first (which latches `ADCH`) and publishes the low byte +plus a done flag through two plain module globals. The compiler detects both as +ISR-shared (volatile semantics) and auto-promotes them to `GPIOR` registers, so +the handoff is single-cycle I/O with zero SRAM. The main loop prints the result +over UART and kicks off the next conversion. ## Hardware @@ -27,7 +29,7 @@ conversion. ## Key concepts - `AnalogPin.irq()` — zero-boilerplate ISR registration -- `GPIOR0`/`GPIOR1` general-purpose I/O registers used as atomic flags/storage +- ISR-shared plain globals — auto-promoted to `GPIOR` registers by the compiler - Reading `ADCL` before `ADCH` to latch a coherent result ## Build & flash diff --git a/examples/adc-interrupt/src/main.py b/examples/adc-interrupt/src/main.py index ce078be3..f0e552a9 100644 --- a/examples/adc-interrupt/src/main.py +++ b/examples/adc-interrupt/src/main.py @@ -4,8 +4,10 @@ # (byte 0x002A / word 0x0015), enables ADIE, and sets SEI -- no # @interrupt decorator or asm("SEI") needed. # -# The ISR reads ADCL first (latches ADCH), stores the low byte in GPIOR1, -# and signals the main loop via GPIOR0[1]. +# The ISR reads ADCL first (latches ADCH), publishes the low byte and a +# done flag through plain module globals. Both are detected as ISR-shared +# (volatile semantics) and auto-promoted to GPIOR registers, so the +# ISR<->main handoff is single-cycle I/O with zero SRAM. # Main loop prints the result over UART and triggers the next conversion. # # Hardware: Arduino Uno @@ -13,15 +15,21 @@ # - UART TX 9600 baud # from pymcu.types import uint8 -from pymcu.chips.atmega328p import GPIOR0, GPIOR1, ADCL, ADCH +from pymcu.chips.atmega328p import ADCL, ADCH from pymcu.hal.uart import UART from pymcu.hal.adc import AnalogPin +# Written by the ISR, read by main. ISR-shared -> auto-promoted to GPIORs; +# both start at 0 on reset. +sample: uint8 = 0 # latest ADC low byte +done: uint8 = 0 # conversion-complete flag + def adc_isr(): # Must read ADCL first to latch ADCH. - GPIOR1.value = ADCL.value - GPIOR0[1] = 1 + global sample, done + sample = ADCL.value + done = 1 def main(): @@ -29,17 +37,15 @@ def main(): adc = AnalogPin("PC0") adc.irq(adc_isr) - GPIOR0[1] = 0 - GPIOR1.value = 0 uart.println("ADC IRQ") # Kick off first conversion adc.start_conversion() while True: - if GPIOR0[1] == 1: - GPIOR0[1] = 0 - result: uint8 = GPIOR1.value + if done == 1: + done = 0 + result: uint8 = sample uart.write(result) uart.write('\n') # Start next conversion diff --git a/examples/analog-inout-mp/pyproject.toml b/examples/analog-inout-mp/pyproject.toml new file mode 100644 index 00000000..6a643261 --- /dev/null +++ b/examples/analog-inout-mp/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "analog-inout-mp" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "pymcu-stdlib>=0.1.0", + "pymcu-micropython>=0.1.0a1", +] + +[tool.pymcu] +board = "arduino_uno" +frequency = 16000000 +sources = "src" +entry = "main.py" +stdlib = ["micropython"] diff --git a/examples/analog-inout-mp/src/main.py b/examples/analog-inout-mp/src/main.py new file mode 100644 index 00000000..299e8048 --- /dev/null +++ b/examples/analog-inout-mp/src/main.py @@ -0,0 +1,21 @@ +# Arduino "AnalogInOutSerial" (03.Analog), ported to PyMCU on the MicroPython +# compatibility layer (machine). +# +# Reads a potentiometer on A0, maps 0-1023 to a 0-255 PWM duty on D6 (OC0A), and +# echoes the duty byte over UART. Exercises machine.ADC, machine.PWM (both Pin +# overloads -- Pin(14) int for A0 and Pin("PD6") string) and machine.UART. +from machine import Pin, ADC, PWM, UART +from pymcu.types import uint8, uint16 + + +def main(): + uart = UART(0, 9600) + pot = ADC(Pin(14)) # Pin(14) == A0 == PC0 (int->name overload) + led = PWM(Pin("PD6")) # OC0A PWM output (str overload) + led.init() + + while True: + sensor: uint16 = pot.read() # 0..1023 + out: uint8 = uint8(sensor >> 2) # map 0..1023 -> 0..255 (Arduino map) + led.duty(out) # analogWrite(D6, out) + uart.write(out) # echo duty byte (sync marker for tests) diff --git a/examples/analog-inout/pyproject.toml b/examples/analog-inout/pyproject.toml new file mode 100644 index 00000000..582ad63c --- /dev/null +++ b/examples/analog-inout/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "analog-inout" +version = "0.1.0" +[tool.pymcu] +target = "atmega328p" +frequency = 16000000 +sources = "src" +entry = "main.py" diff --git a/examples/analog-inout/src/main.py b/examples/analog-inout/src/main.py new file mode 100644 index 00000000..47125bd8 --- /dev/null +++ b/examples/analog-inout/src/main.py @@ -0,0 +1,19 @@ +# Arduino "AnalogInOutSerial" (03.Analog), native PyMCU HAL. +# Read A0, map 0-1023 -> 0-255 PWM duty on D6, echo duty over UART. +from pymcu.types import uint8, uint16 +from pymcu.hal.uart import UART +from pymcu.hal.adc import AnalogPin +from pymcu.hal.pwm import PWM + + +def main(): + uart = UART(9600) + pot = AnalogPin("PC0") + led = PWM("PD6", 0) + led.start() + + while True: + sensor: uint16 = pot.read() + out: uint8 = uint8(sensor >> 2) + led.set_duty(out) + uart.write(out) diff --git a/examples/dht-sensor/src/dht11.py b/examples/dht-sensor/src/dht11.py deleted file mode 100644 index 184dad11..00000000 --- a/examples/dht-sensor/src/dht11.py +++ /dev/null @@ -1,83 +0,0 @@ -from pymcu.types import uint8, uint16, inline -from pymcu.hal.gpio import Pin -from pymcu.time import delay_ms, delay_us - - -class DHT11: - @inline - def __init__(self, pin: Pin): - self.pin = pin - self.failed = False - self.temperature = 0 - self.humidity = 0 - - @inline - def measure(self): - # 1. Start Signal (MCU) - # Pull the line LOW for at least 18ms to wake the DHT11 - self.pin.mode(Pin.OUT) - self.pin.low() - delay_ms(18) - - # Pull HIGH and wait 20-40us for the sensor to take control - self.pin.high() - delay_us(30) - self.pin.mode(Pin.IN) - - # 2. Acknowledge (Sensor) - # Sensor responds by pulling LOW ~80us then HIGH ~80us. - # pulse_in(0) waits and measures the LOW pulse from the sensor. - ack_low = self.pin.pulse_in(0, 1000) - if ack_low == 0: - self.failed = True - return 0xFFFF # Timeout - no sensor connected or failure - - # Wait for end of HIGH confirmation pulse - ack_high = self.pin.pulse_in(1, 1000) - if ack_high == 0: - self.failed = True - return 0xFFFF - - # 3. Read 5 bytes (40 bits) - hum_int = self._read_byte() - hum_dec = self._read_byte() # In DHT11 this is normally 0 - temp_int = self._read_byte() - temp_dec = self._read_byte() # In DHT11 this is normally 0 - checksum = self._read_byte() - - # 4. Validate Checksum - # Checksum is the sum of the first 4 bytes truncated to 8 bits - expected_chk = (hum_int + hum_dec + temp_int + temp_dec) & 0xFF - - if checksum != expected_chk: - self.failed = True - return 0xFFFF - - # 5. Success: save state - self.failed = False - self.humidity = hum_int - self.temperature = temp_int - - def _read_byte(self) -> uint8: - # Read 8 consecutive bits from the sensor. - result: uint8 = 0 - i: uint8 = 0 - - while i < 8: - # Each bit starts with 50us LOW (ignored). - # The actual bit is determined by how long the line stays HIGH: - # ~26-28us = logical '0' - # ~70us = logical '1' - # pulse_in(1, 100) waits for HIGH and measures duration. - duration: uint16 = self.pin.pulse_in(1, 1000) - - result = result << 1 - - # Use 40us as safe threshold to decide 0 or 1 - if duration > 40: - result = result | 1 - - i = i + 1 - - return result - diff --git a/examples/dht-sensor/src/main.py b/examples/dht-sensor/src/main.py index 915a9f4a..29f894b1 100644 --- a/examples/dht-sensor/src/main.py +++ b/examples/dht-sensor/src/main.py @@ -3,10 +3,14 @@ # Demonstrates: # - Board abstractions: pymcu.boards.arduino_uno # - Stdlib driver: pymcu.drivers.dht11 (arch-neutral ZCA, same pattern as Pin/UART) -# - Multi-file project: main.py is the only local file; drivers live in the stdlib +# - Multi-file project: main.py is the only local file; the driver lives in the stdlib # -# sensor = DHT11("PD2") → D2 = "PD2" bound at compile time -# sensor.read() → match __CHIP__.arch → match pin_name (const[str]) → direct protocol +# sensor = DHT11(D2) → D2 = "PD2" bound at compile time +# sensor.read() → match __CHIP__.arch → match pin_name (const[str]) → shared protocol +# +# The driver stores only a const[str] pin name (a scalar field), so DHT11.read() is a +# zero-cost abstraction: the heavy bit-bang protocol lives in one shared _pd_read(bit) +# subroutine, not duplicated per instance. # # Wiring: # DHT11 DATA → Arduino D2 (4.7 kΩ pull-up to +5 V) @@ -21,31 +25,28 @@ from pymcu.hal.gpio import Pin from pymcu.hal.uart import UART from pymcu.time import delay_ms -from dht11 import DHT11 - - -def nibble_hex(n: uint8) -> uint8: - if n < 10: - return n + 48 - return n + 55 +from pymcu.drivers.dht11 import DHT11 def main(): - uart = UART(9600) - led = Pin(LED_BUILTIN, Pin.OUT) - data_pin = Pin(D2, Pin.IN) # DHT11 data pin — driver switches direction at runtime - sensor = DHT11(data_pin) # ZCA: sensor.pin tracks data_pin.name compile-time + uart = UART(9600) + led = Pin(LED_BUILTIN, Pin.OUT) + sensor = DHT11(D2) # ZCA: stores the const "PD2" name, no per-instance protocol copy print("DHT11") while True: - sensor.measure() + # read() returns uint16: high byte = humidity %, low byte = temperature °C. + # 0xFFFF signals a failure (no sensor, timeout or checksum mismatch). + data: uint16 = sensor.read() - if sensor.failed: + if data == 0xFFFF: print("ERR") led.low() else: - print("H:", sensor.humidity, " T:", sensor.temperature, sep="") + humidity: uint8 = uint8(data >> 8) + temperature: uint8 = uint8(data & 0xFF) + print("H:", humidity, " T:", temperature, sep="") led.high() delay_ms(2000) diff --git a/examples/i2c-irq/README.md b/examples/i2c-irq/README.md index 9ead0dbe..c01d815f 100644 --- a/examples/i2c-irq/README.md +++ b/examples/i2c-irq/README.md @@ -7,7 +7,8 @@ Interrupt-driven I2C **peripheral** (TWI slave) at address 0x42. Configures the TWI hardware as an I2C peripheral and registers an ISR with `i2c.irq(handler)` (no `@interrupt` decorator or manual `TWIE`/`SEI` writes). The ISR runs the TWI state machine — inspecting `TWSR`, ACKing each event, and -saving received data bytes to `GPIOR0` for the main loop to print as hex. +saving received data bytes to a plain module global (auto-promoted to a `GPIOR` +register by the compiler) for the main loop to print as hex. > The ISR **must** re-arm the interrupt by writing `TWCR` with `TWINT=1` > (`0xC4 = TWINT|TWEA|TWEN`) or the peripheral stalls. @@ -26,7 +27,7 @@ saving received data bytes to `GPIOR0` for the main loop to print as hex. ## Key concepts - TWI peripheral mode + interrupt-driven state machine -- `GPIOR0` as an atomic flag between ISR and main loop +- ISR-shared plain global between ISR and main — auto-promoted to a `GPIOR` register ## Build & flash diff --git a/examples/i2c-irq/src/main.py b/examples/i2c-irq/src/main.py index a4cde8f2..25bc4a5c 100644 --- a/examples/i2c-irq/src/main.py +++ b/examples/i2c-irq/src/main.py @@ -5,7 +5,9 @@ # - i2c.irq(handler): register ISR at TWI vector via compile_isr; # no @interrupt decorator, TWCR.TWIE write, or asm("SEI") needed # - TWI state machine in the ISR: check TWSR, ACK each event -# - GPIOR0 atomic storage: SBI/CBI are single-cycle IO instructions +# - ISR<->main handoff through a plain module global: detected as +# ISR-shared (volatile semantics) and auto-promoted to a GPIOR +# register, so the received byte moves through single-cycle I/O # # Hardware: Arduino Uno as I2C peripheral # SDA = PC4 (Arduino pin A4) -- controlled by TWI hardware @@ -17,22 +19,30 @@ # (bit 7) to re-arm the interrupt; otherwise the peripheral stalls. # TWCR = 0xC4: TWINT(7)|TWEA(6)|TWEN(2). # +# Note: a received 0x00 byte is indistinguishable from "no data" in this +# simple demo -- the main loop only reports non-zero bytes. +# # Output: # "I2CI\n" -- boot banner # "XX\n" -- two hex digits for each data byte received from controller # from pymcu.types import uint8 -from pymcu.chips.atmega328p import TWSR, TWDR, TWCR, GPIOR0 +from pymcu.chips.atmega328p import TWSR, TWDR, TWCR from pymcu.hal.i2c import I2C from pymcu.hal.uart import UART +# Last received data byte, written by the ISR and consumed by main. +# ISR-shared -> auto-promoted to a GPIOR register; starts at 0 on reset. +rx: uint8 = 0 + def on_event(): + global rx status: uint8 = TWSR.value & 0xF8 if status == 0x60: # own SLA+W received, ACK returned TWCR.value = 0xC4 # TWINT | TWEA | TWEN -- ready for data elif status == 0x80: # data byte received, ACK returned - GPIOR0[0] = TWDR.value # save byte for main loop + rx = TWDR.value # save byte for main loop TWCR.value = 0xC4 elif status == 0xA0: # STOP or repeated START received TWCR.value = 0xC4 @@ -48,12 +58,11 @@ def main(): # No @interrupt decorator or asm("SEI") needed. i2c.irq(on_event) - GPIOR0[0] = 0 uart.println("I2CI") while True: - if GPIOR0[0] != 0: - byte: uint8 = GPIOR0[0] - GPIOR0[0] = 0 + if rx != 0: + byte: uint8 = rx + rx = 0 uart.write_hex(byte) uart.write('\n') diff --git a/examples/interrupt-counter/README.md b/examples/interrupt-counter/README.md index 81681c2c..9340e5d2 100644 --- a/examples/interrupt-counter/README.md +++ b/examples/interrupt-counter/README.md @@ -6,7 +6,8 @@ External interrupt (INT0) press counter. `btn.irq(Pin.IRQ_FALLING, int0_isr)` configures the INT0 hardware interrupt on **PD2** (falling edge), enables the interrupt mask, and sets `SEI` — no manual -`EICRA`/`EIMSK` writes. The ISR sets an atomic flag in `GPIOR0`; the main loop +`EICRA`/`EIMSK` writes. The ISR sets a plain module global — the compiler +detects it as ISR-shared and auto-promotes it to `GPIOR0` — and the main loop clears it, increments a counter, toggles the LED, and sends the raw count over UART. @@ -20,7 +21,7 @@ UART. ## Key concepts - `Pin.irq()` for hardware external interrupts -- `GPIOR0` SBI/CBI atomic flag pattern (ISR sets, main clears) +- ISR-shared plain global (ISR sets, main clears) — auto-promoted to `GPIOR0`, compiles to SBI/CBI/IN/OUT ## Build & flash diff --git a/examples/interrupt-counter/src/main.py b/examples/interrupt-counter/src/main.py index 38bd0d84..d303e7d1 100644 --- a/examples/interrupt-counter/src/main.py +++ b/examples/interrupt-counter/src/main.py @@ -2,7 +2,9 @@ # # Demonstrates: # - Pin.irq(): sets up INT0 (IRQ_FALLING), enables interrupt mask and SEI automatically -# - GPIOR0 atomic flag pattern: ISR sets bit (SBI), main clears it (CBI) +# - ISR<->main signaling through a plain module global: the compiler detects +# it as ISR-shared (volatile semantics) and promotes it to GPIOR0, so the +# flag accesses compile to single-cycle OUT/IN — no manual GPIOR idiom # - No manual EICRA/EIMSK register writes or asm("SEI") needed # # Hardware: Arduino Uno @@ -11,14 +13,18 @@ # - Serial terminal at 9600 baud — receives count byte on each press # from pymcu.types import uint8 -from pymcu.chips.atmega328p import GPIOR0 from pymcu.hal.gpio import Pin from pymcu.hal.uart import UART +# Set by the ISR, polled and cleared by main. ISR-shared -> auto-promoted +# to GPIOR0; starts at 0 on reset. +pressed: uint8 = 0 + def int0_isr(): - # Minimal ISR: set event flag with SBI (atomic, no registers corrupted) - GPIOR0[0] = 1 + # Minimal ISR: set the shared event flag (compiles to OUT on GPIOR0) + global pressed + pressed = 1 def main(): @@ -26,15 +32,14 @@ def main(): btn = Pin("PD2", Pin.IN, pull=Pin.PULL_UP) uart = UART(9600) - GPIOR0[0] = 0 btn.irq(Pin.IRQ_FALLING, int0_isr) # configures INT0 + enables SEI count: uint8 = 0 uart.println("INT COUNTER") while True: - if GPIOR0[0] == 1: # SBIS 0x1E, 0 - GPIOR0[0] = 0 # CBI 0x1E, 0 + if pressed == 1: + pressed = 0 count += 1 led.toggle() uart.write(count) # send raw count byte over UART diff --git a/examples/pcint-counter/README.md b/examples/pcint-counter/README.md index d0b27fba..194a4baa 100644 --- a/examples/pcint-counter/README.md +++ b/examples/pcint-counter/README.md @@ -5,7 +5,8 @@ Pin Change Interrupt (PCINT0) button counter. ## What it does A button on **PB0** fires PCINT0 on any edge. `btn.irq(3, pcint0_isr)` sets -`PCMSK0`, `PCICR`, and `SEI` automatically. The ISR sets a `GPIOR0` flag; the +`PCMSK0`, `PCICR`, and `SEI` automatically. The ISR sets a plain module global +(auto-promoted to `GPIOR0` by the compiler); the main loop reads PB0 to distinguish a press (low) from a release (high) and only counts presses, printing `COUNT:NN`. diff --git a/examples/pcint-counter/src/main.py b/examples/pcint-counter/src/main.py index 6440da27..3247de3c 100644 --- a/examples/pcint-counter/src/main.py +++ b/examples/pcint-counter/src/main.py @@ -5,17 +5,22 @@ # - Serial terminal at 9600 baud: prints "COUNT:NN\n" on each press # # PCINT0 fires on any edge of PB0. btn.irq() sets PCMSK0[0], PCICR[0], -# and SEI automatically. The ISR sets a GPIOR0 flag; main reads PB0 to -# distinguish press (low) from release (high). +# and SEI automatically. The ISR sets a plain module global -- detected as +# ISR-shared (volatile semantics) and auto-promoted to GPIOR0 -- and main +# reads PB0 to distinguish press (low) from release (high). # from pymcu.types import uint8 -from pymcu.chips.atmega328p import GPIOR0 from pymcu.hal.gpio import Pin from pymcu.hal.uart import UART +# Set by the ISR on any edge, polled and cleared by main. ISR-shared -> +# auto-promoted to GPIOR0; starts at 0 on reset. +edge: uint8 = 0 + def pcint0_isr(): - GPIOR0[0] = 1 + global edge + edge = 1 def main(): @@ -23,18 +28,17 @@ def main(): uart = UART(9600) btn.irq(3, pcint0_isr) - GPIOR0[0] = 0 uart.println("PCINT COUNTER") count: uint8 = 0 while True: - if GPIOR0[0] == 1: - GPIOR0[0] = 0 + if edge == 1: + edge = 0 # Only count falling edges (button pressed = low) if btn.value() == 0: count += 1 uart.write_str("COUNT:") - uart.write((count / 10) + 48) + uart.write((count // 10) + 48) uart.write((count % 10) + 48) uart.write('\n') diff --git a/examples/pin-irq/README.md b/examples/pin-irq/README.md index d2de6892..18e72682 100644 --- a/examples/pin-irq/README.md +++ b/examples/pin-irq/README.md @@ -6,8 +6,9 @@ Minimal external interrupt: INT0 falling-edge on PD2. The simplest possible `Pin.irq()` demo. `btn.irq(Pin.IRQ_FALLING, on_press)` configures INT0 for falling-edge triggering and enables global interrupts. The -ISR sets an atomic `GPIOR0` flag; the main loop counts presses and sends the raw -count byte over UART. +ISR sets a plain module global — the compiler detects it as ISR-shared and +auto-promotes it to `GPIOR0` — and the main loop counts presses and sends the +raw count byte over UART. A good companion to [`interrupt-counter`](../interrupt-counter) — start here for the bare mechanics of pin interrupts. diff --git a/examples/pin-irq/src/main.py b/examples/pin-irq/src/main.py index a9810d94..10a65407 100644 --- a/examples/pin-irq/src/main.py +++ b/examples/pin-irq/src/main.py @@ -4,6 +4,10 @@ # - Pin.irq(Pin.IRQ_FALLING, handler) to configure hardware INT0 # - compile_isr() intrinsic auto-registers on_press at INT0 vector # - No @interrupt decorator required on the handler function +# - ISR<->main signaling through a plain module global: the compiler +# detects it as ISR-shared (volatile semantics) and promotes it to +# GPIOR0, so the ISR write and the main poll compile to single-cycle +# OUT/IN on an I/O register -- zero SRAM, no manual GPIOR idiom # - EICRA[1]=1, EICRA[0]=0 -> falling edge; EIMSK[0]=1 -> INT0 enable # - SREG[7]=1 -> global interrupts enabled (sei) # @@ -16,22 +20,24 @@ # count byte -- raw byte (1, 2, 3...) sent on each button press # from pymcu.types import uint8 -from pymcu.chips.atmega328p import GPIOR0 from pymcu.hal.gpio import Pin from pymcu.hal.uart import UART +# Set by the ISR, polled and cleared by main. ISR-shared -> auto-promoted +# to GPIOR0 (always-volatile I/O storage); starts at 0 on reset. +pressed: uint8 = 0 + def on_press(): - # Minimal ISR: set atomic flag using SBI (no register corruption) - GPIOR0[0] = 1 + # Minimal ISR: set the shared flag (compiles to OUT on GPIOR0) + global pressed + pressed = 1 def main(): btn = Pin("PD2", Pin.IN, pull=Pin.PULL_UP) uart = UART(9600) - GPIOR0[0] = 0 - # Configure INT0 for falling-edge trigger and enable global interrupts. # compile_isr() inside pin_irq_setup automatically places on_press at # the INT0 vector -- no @interrupt decorator needed. @@ -41,7 +47,7 @@ def main(): uart.println("PIN IRQ") while True: - if GPIOR0[0] == 1: - GPIOR0[0] = 0 + if pressed == 1: + pressed = 0 count = count + 1 uart.write(count) diff --git a/examples/rtos-multitask/src/main.py b/examples/rtos-multitask/src/main.py index 51d984cf..6c5e8169 100644 --- a/examples/rtos-multitask/src/main.py +++ b/examples/rtos-multitask/src/main.py @@ -37,6 +37,7 @@ def sensor_task(): # Highest priority (3): reads the free-running TCNT0 hardware counter as # a pseudo-ADC input, runs it through a CRC-8/MAXIM pipeline and a # bit-reversal, then publishes the result to _crc_out. + global _crc_out while True: raw: uint8 = 0 asm("IN %0, 0x26", raw) diff --git a/examples/sensor-dashboard/README.md b/examples/sensor-dashboard/README.md index 749d7012..2fa3f54a 100644 --- a/examples/sensor-dashboard/README.md +++ b/examples/sensor-dashboard/README.md @@ -25,7 +25,7 @@ button (**PD2**) toggle between a verbose and a compact UART display. ## Key concepts - Two simultaneous interrupt sources (Timer0 OVF + INT0) -- `GPIOR0` bit flags coordinating ISRs and the main loop +- ISR-shared plain globals coordinating ISRs and the main loop — auto-promoted to `GPIOR` registers - Min/max + EMA filtering ## Build & flash diff --git a/examples/sensor-dashboard/src/main.py b/examples/sensor-dashboard/src/main.py index 051bbc7e..9f11f942 100644 --- a/examples/sensor-dashboard/src/main.py +++ b/examples/sensor-dashboard/src/main.py @@ -5,6 +5,10 @@ # Blinks PB5 LED on each sample. INT0 (PD2, falling edge) toggles verbose # <-> compact display mode. # +# Each ISR signals main through its own plain module global. Both are +# detected as ISR-shared (volatile semantics) and auto-promoted to GPIOR +# registers -- no manual GPIOR idiom needed. +# # Hardware: Arduino Uno # ADC input: PC0 (A0) # LED: PB5 (Arduino pin 13) @@ -19,19 +23,26 @@ # 64 overflows => ADC sample every ~262 ms # from pymcu.types import uint8, uint16 -from pymcu.chips.atmega328p import GPIOR0, TIFR0 +from pymcu.chips.atmega328p import TIFR0 from pymcu.hal.gpio import Pin from pymcu.hal.uart import UART from pymcu.hal.timer import Timer from pymcu.hal.adc import AnalogPin +# One flag per ISR, polled and cleared by main. ISR-shared -> auto-promoted +# to GPIOR registers; both start at 0 on reset. +ovf_f: uint8 = 0 # Timer0 overflow +btn_f: uint8 = 0 # INT0 mode toggle + def timer0_ovf_isr(): - GPIOR0[0] = 1 + global ovf_f + ovf_f = 1 def int0_isr(): - GPIOR0[1] = 1 + global btn_f + btn_f = 1 def main(): @@ -44,8 +55,6 @@ def main(): timer.irq(timer0_ovf_isr) btn.irq(Pin.IRQ_FALLING, int0_isr) - GPIOR0[0] = 0 - GPIOR0[1] = 0 uart.println("SENSOR DASHBOARD") raw: uint8 = 0 @@ -57,8 +66,8 @@ def main(): while True: # Timer0 OVF: count ticks, sample ADC every 64 (~262 ms) - if GPIOR0[0] == 1: - GPIOR0[0] = 0 + if ovf_f == 1: + ovf_f = 0 TIFR0[0] = 1 # clear TOV0 flag (write-1-to-clear) tick += 1 if tick == 64: @@ -91,8 +100,8 @@ def main(): uart.write('\n') # INT0: toggle verbose/compact mode - if GPIOR0[1] == 1: - GPIOR0[1] = 0 + if btn_f == 1: + btn_f = 0 if verbose == 1: verbose = 0 uart.println("MODE:COMPACT") diff --git a/examples/sleep-wakeup/README.md b/examples/sleep-wakeup/README.md index 59c68d1e..74597ae2 100644 --- a/examples/sleep-wakeup/README.md +++ b/examples/sleep-wakeup/README.md @@ -5,7 +5,8 @@ Enter sleep mode and wake on an external interrupt. ## What it does Puts the MCU into idle sleep with `sleep_idle()`, using an INT0 button (**PD2**) -as the wake source. The wake ISR sets a `GPIOR0` flag; the main loop wakes, +as the wake source. The wake ISR sets a plain module global (auto-promoted to +`GPIOR0` by the compiler); the main loop wakes, prints `WAKE`, and repeats five times before printing `DONE`. ## Hardware diff --git a/examples/sleep-wakeup/src/main.py b/examples/sleep-wakeup/src/main.py index 78aa8a9f..a4d5018e 100644 --- a/examples/sleep-wakeup/src/main.py +++ b/examples/sleep-wakeup/src/main.py @@ -3,19 +3,24 @@ # Demonstrates: # - sleep_idle() from pymcu.hal.power # - Pin.irq(): sets up INT0 wake source, enables interrupt mask and SEI automatically -# - GPIOR0 atomic flag: ISR sets bit on wake, main clears and acts +# - ISR<->main signaling through a plain module global: detected as +# ISR-shared (volatile semantics) and auto-promoted to GPIOR0 # # Hardware: button on PD2 (INT0, pull-up), UART at 9600 baud # from pymcu.types import uint8 -from pymcu.chips.atmega328p import GPIOR0 from pymcu.hal.uart import UART from pymcu.hal.gpio import Pin from pymcu.hal.power import sleep_idle +# Set by the ISR on wake, polled and cleared by main. ISR-shared -> +# auto-promoted to GPIOR0; starts at 0 on reset. +woke: uint8 = 0 + def int0_isr(): - GPIOR0[0] = 1 # set wakeup flag + global woke + woke = 1 # set wakeup flag def main(): @@ -24,15 +29,14 @@ def main(): uart.println("SLEEP DEMO") - GPIOR0[0] = 0 btn.irq(Pin.IRQ_FALLING, int0_isr) # configures INT0 + enables SEI count: uint8 = 0 while count < 5: uart.println("SLEEP") sleep_idle() - if GPIOR0[0] == 1: - GPIOR0[0] = 0 + if woke == 1: + woke = 0 count += 1 uart.println("WAKE") diff --git a/examples/smoothing/pyproject.toml b/examples/smoothing/pyproject.toml new file mode 100644 index 00000000..2ef67c8f --- /dev/null +++ b/examples/smoothing/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "smoothing" +version = "0.1.0" +dependencies = ["pymcu-stdlib"] + +[tool.pymcu] +target = "atmega328p" +frequency = 16000000 +sources = "src" +entry = "main.py" diff --git a/examples/smoothing/src/main.py b/examples/smoothing/src/main.py new file mode 100644 index 00000000..116a6b7c --- /dev/null +++ b/examples/smoothing/src/main.py @@ -0,0 +1,32 @@ +# Arduino "Smoothing" example, ported to PyMCU. +# Keeps a running average of the last 10 analog readings from A0 (PC0) and +# prints it over UART -- the classic sliding-window smoothing filter. +# +# Original: File > Examples > 03.Analog > Smoothing +# Wiring: potentiometer / sensor wiper -> A0, UART TX at 9600 baud. +from pymcu.types import uint8, uint16 +from pymcu.hal.uart import UART +from pymcu.hal.adc import AnalogPin +from pymcu.time import delay_ms + + +def main(): + uart = UART(9600) + sensor = AnalogPin("PC0") + readings: uint16[10] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + index: uint8 = 0 + total: uint16 = 0 + + while True: + # subtract the oldest reading, sample, add the newest + total = total - readings[index] + readings[index] = sensor.read() + total = total + readings[index] + + index = index + 1 + if index >= 10: + index = 0 + + average: uint16 = total // 10 + print(average) + delay_ms(1) diff --git a/examples/soft-pwm/README.md b/examples/soft-pwm/README.md index bef6cd28..2c335be3 100644 --- a/examples/soft-pwm/README.md +++ b/examples/soft-pwm/README.md @@ -4,7 +4,8 @@ Software PWM driven by a timer overflow ISR. ## What it does -A Timer0 overflow ISR (~4.1 ms) sets a `GPIOR0` flag. The main loop keeps a +A Timer0 overflow ISR (~4.1 ms) sets a plain module global (auto-promoted to +`GPIOR0` by the compiler). The main loop keeps a 0–99 counter and turns the LED on while `counter < duty`. The duty cycle bounces through 0 → 25 → 50 → 75 → 100 → 75 → … every 100 ticks, demonstrating PWM without a dedicated PWM output pin. @@ -17,7 +18,7 @@ without a dedicated PWM output pin. ## Key concepts -- Timer overflow ISR + `GPIOR0` flag +- Timer overflow ISR + ISR-shared plain global (auto-promoted to `GPIOR0`) - Software PWM via a duty-cycle counter - `match`/`case` lookup table (`duty_value()`) diff --git a/examples/soft-pwm/src/main.py b/examples/soft-pwm/src/main.py index d910d39f..edbefd17 100644 --- a/examples/soft-pwm/src/main.py +++ b/examples/soft-pwm/src/main.py @@ -1,6 +1,7 @@ # ATmega328P: Software PWM via Timer0 overflow ISR + duty-cycle counter # -# Timer0 OVF ISR (~4.1ms at 16MHz/1024) sets GPIOR0[0] flag. +# Timer0 OVF ISR (~4.1ms at 16MHz/1024) sets a plain module global -- +# detected as ISR-shared (volatile semantics) and auto-promoted to GPIOR0. # Main loop maintains a 0-99 counter; while counter < duty the LED is on. # Duty cycle bounces: 0 -> 25 -> 50 -> 75 -> 100 -> 75 -> 50 -> 25 -> 0. # Each step lasts 100 timer ticks (~0.41 s). @@ -10,14 +11,18 @@ # UART TX on PD1 at 9600 baud # from pymcu.types import uint8 -from pymcu.chips.atmega328p import GPIOR0 from pymcu.hal.gpio import Pin from pymcu.hal.uart import UART from pymcu.hal.timer import Timer +# Set by the ISR, polled and cleared by main. ISR-shared -> auto-promoted +# to GPIOR0; starts at 0 on reset. +tick: uint8 = 0 + def timer0_ovf_isr(): - GPIOR0[0] = 1 + global tick + tick = 1 # Returns the duty cycle percentage (0-100) for a given bounce phase (0-7). @@ -40,7 +45,6 @@ def main(): timer = Timer(0, 1024) timer.irq(timer0_ovf_isr) - GPIOR0[0] = 0 uart.println("SOFT PWM") counter: uint8 = 0 @@ -49,8 +53,8 @@ def main(): phase: uint8 = 0 while True: - if GPIOR0[0] == 1: - GPIOR0[0] = 0 + if tick == 1: + tick = 0 counter = counter + 1 if counter >= 100: diff --git a/examples/spi-irq/src/main.py b/examples/spi-irq/src/main.py index 282a404b..645a8cdb 100644 --- a/examples/spi-irq/src/main.py +++ b/examples/spi-irq/src/main.py @@ -4,7 +4,9 @@ # - SPI(SPI.PERIPHERAL): configure hardware SPI in peripheral mode # - spi.irq(handler): register ISR at SPI STC vector via compile_isr; # no @interrupt decorator or manual SPCR/SREG writes needed -# - GPIOR0 atomic flag pattern: ISR stores byte, main loop reads it +# - ISR<->main handoff through a plain module global: detected as +# ISR-shared (volatile semantics) and auto-promoted to a GPIOR +# register, so the received byte moves through single-cycle I/O # # Hardware: Arduino Uno as SPI peripheral (connect to any SPI controller) # MISO = PB4 (Arduino pin 12) -- data out (peripheral drives this) @@ -16,19 +18,27 @@ # ISR contract: on_byte() MUST read SPDR to clear SPIF; otherwise the # interrupt re-fires immediately. SPDR holds the byte from the controller. # +# Note: a received 0x00 byte is indistinguishable from "no data" in this +# simple demo -- the main loop only reports non-zero bytes. +# # Output: # "SPII\n" -- boot banner # "XX\n" -- two hex digits for each byte received from controller # from pymcu.types import uint8 -from pymcu.chips.atmega328p import SPDR, GPIOR0 +from pymcu.chips.atmega328p import SPDR from pymcu.hal.spi import SPI from pymcu.hal.uart import UART +# Last received byte, written by the ISR and consumed by main. +# ISR-shared -> auto-promoted to a GPIOR register; starts at 0 on reset. +rx: uint8 = 0 + def on_byte(): # Reading SPDR clears SPIF and captures the received byte atomically. - GPIOR0[0] = SPDR.value + global rx + rx = SPDR.value def main(): @@ -39,12 +49,11 @@ def main(): # No @interrupt decorator or asm("SEI") needed. spi.irq(on_byte) - GPIOR0[0] = 0 uart.println("SPII") while True: - if GPIOR0[0] != 0: - byte: uint8 = GPIOR0[0] - GPIOR0[0] = 0 + if rx != 0: + byte: uint8 = rx + rx = 0 uart.write_hex(byte) uart.write('\n') diff --git a/examples/stopwatch/README.md b/examples/stopwatch/README.md index 59799d09..ecf896a2 100644 --- a/examples/stopwatch/README.md +++ b/examples/stopwatch/README.md @@ -11,7 +11,8 @@ Runs three simultaneous ISRs: - **TIMER0_OVF** — ~16.384 ms tick (61 ticks ≈ 1 second) The LED is on while running; elapsed seconds are sent over UART as a raw byte + -newline. State is coordinated through `GPIOR0` bit flags. +newline. Each ISR signals main through its own plain module global — all three +are auto-promoted to `GPIOR0/1/2` by the compiler. ## Hardware @@ -23,7 +24,7 @@ newline. State is coordinated through `GPIOR0` bit flags. ## Key concepts - Three concurrent interrupt sources -- `GPIOR0` bit flags for ISR ↔ main communication +- ISR-shared plain globals for ISR ↔ main communication — auto-promoted to `GPIOR` registers ## Build & flash diff --git a/examples/stopwatch/src/main.py b/examples/stopwatch/src/main.py index 4044d889..c33aedc8 100644 --- a/examples/stopwatch/src/main.py +++ b/examples/stopwatch/src/main.py @@ -8,10 +8,9 @@ # 61 ticks ~= 1 second. Main loop tracks elapsed seconds and sends them # over UART as a raw uint8 byte + '\n' whenever seconds increments. # -# State machine via GPIOR0 bit flags: -# GPIOR0[0] = Timer0 OVF flag -# GPIOR0[1] = INT0 Start/Stop flag -# GPIOR0[2] = INT1 Reset flag +# Each ISR signals main through its own plain module global. All three are +# detected as ISR-shared (volatile semantics) and auto-promoted to +# GPIOR0/1/2, so every flag access is single-cycle I/O with zero SRAM. # # Hardware: Arduino Uno # Start/Stop button: PD2 (Arduino pin 2), active low, pull-up @@ -25,23 +24,32 @@ # On reset: sends 0, '\n' # from pymcu.types import uint8, uint16 -from pymcu.chips.atmega328p import PORTB, DDRB, GPIOR0 +from pymcu.chips.atmega328p import PORTB, DDRB from pymcu.chips.atmega328p import TCCR0B, TIMSK0 from pymcu.hal.gpio import Pin from pymcu.hal.timer import Timer from pymcu.hal.uart import UART +# One flag per ISR, polled and cleared by main. ISR-shared -> auto-promoted +# to GPIOR registers; all start at 0 on reset. +tick_f: uint8 = 0 # Timer0 OVF +start_f: uint8 = 0 # INT0 start/stop +reset_f: uint8 = 0 # INT1 reset + def timer0_ovf_isr(): - GPIOR0[0] = 1 + global tick_f + tick_f = 1 def int0_isr(): - GPIOR0[1] = 1 + global start_f + start_f = 1 def int1_isr(): - GPIOR0[2] = 1 + global reset_f + reset_f = 1 def main(): @@ -57,10 +65,6 @@ def main(): btn_start = Pin("PD2", Pin.IN, pull=Pin.PULL_UP) btn_reset = Pin("PD3", Pin.IN, pull=Pin.PULL_UP) - GPIOR0[0] = 0 - GPIOR0[1] = 0 - GPIOR0[2] = 0 - btn_start.irq(Pin.IRQ_FALLING, int0_isr) btn_reset.irq(Pin.IRQ_FALLING, int1_isr) @@ -73,8 +77,8 @@ def main(): while True: # Handle INT0: toggle start/stop - if GPIOR0[1] == 1: - GPIOR0[1] = 0 + if start_f == 1: + start_f = 0 if running == 0: running = 1 PORTB[5] = 1 # LED on while running @@ -83,8 +87,8 @@ def main(): PORTB[5] = 0 # Handle INT1: reset - if GPIOR0[2] == 1: - GPIOR0[2] = 0 + if reset_f == 1: + reset_f = 0 ticks = 0 seconds = 0 running = 0 @@ -93,8 +97,8 @@ def main(): uart.write('\n') # Handle Timer0 tick while running - if GPIOR0[0] == 1: - GPIOR0[0] = 0 + if tick_f == 1: + tick_f = 0 if running == 1: ticks += 1 if ticks >= 61: diff --git a/examples/timer-ctc/src/main.py b/examples/timer-ctc/src/main.py index c8fa18ee..8d788ead 100644 --- a/examples/timer-ctc/src/main.py +++ b/examples/timer-ctc/src/main.py @@ -7,19 +7,26 @@ # TIMER1_COMPA vector and enables OCIE1A + SEI automatically. # No @interrupt decorator or manual TIMSK/SEI writes needed. # +# The ISR signals main through a plain module global: detected as +# ISR-shared (volatile semantics) and auto-promoted to GPIOR0. +# # Hardware: Arduino Uno # - LED on PB5 (built-in, pin 13) # - UART TX 9600 baud: sends "CTC\n" on boot, "C\n" on each 1 Hz tick # from pymcu.types import uint8 -from pymcu.chips.atmega328p import GPIOR0 from pymcu.hal.gpio import Pin from pymcu.hal.uart import UART from pymcu.hal.timer import Timer +# Set by the ISR, polled and cleared by main. ISR-shared -> auto-promoted +# to GPIOR0; starts at 0 on reset. +tick: uint8 = 0 + def timer1_compa_isr(): - GPIOR0[0] = 1 + global tick + tick = 1 def main(): @@ -30,13 +37,11 @@ def main(): t.set_compare(62499) t.irq(timer1_compa_isr, Timer.IRQ_COMPA) # places ISR at TIMER1_COMPA vector - GPIOR0[0] = 0 - uart.println("CTC") while True: - if GPIOR0[0] == 1: - GPIOR0[0] = 0 + if tick == 1: + tick = 0 led.toggle() uart.write('C') - uart.write('\n') \ No newline at end of file + uart.write('\n') diff --git a/examples/timer-interrupt/README.md b/examples/timer-interrupt/README.md index 11580ad3..ba265b22 100644 --- a/examples/timer-interrupt/README.md +++ b/examples/timer-interrupt/README.md @@ -6,8 +6,9 @@ Timer1 overflow interrupt blinking an LED at ~1 Hz. `Timer(1, 256).irq(on_overflow)` registers an ISR at the Timer1 overflow vector and enables `TOIE1` + `SEI` automatically. At prescaler 256 the 16-bit timer -overflows about every 1.05 s; the ISR sets a `GPIOR0` flag and the main loop -toggles the LED and sends `T` over UART. +overflows about every 1.05 s; the ISR sets a plain module global (auto-promoted +to `GPIOR0` by the compiler) and the main loop toggles the LED and sends `T` +over UART. A simpler counterpart to [`timer-ctc`](../timer-ctc) (overflow vs. compare-match). @@ -20,7 +21,7 @@ A simpler counterpart to [`timer-ctc`](../timer-ctc) (overflow vs. compare-match ## Key concepts - `Timer.irq()` overflow interrupt registration -- `GPIOR0` atomic flag pattern +- ISR-shared plain global — auto-promoted to `GPIOR0` (volatile semantics, single-cycle I/O) ## Build & flash diff --git a/examples/timer-interrupt/src/main.py b/examples/timer-interrupt/src/main.py index ec25cf45..14f8ee19 100644 --- a/examples/timer-interrupt/src/main.py +++ b/examples/timer-interrupt/src/main.py @@ -4,7 +4,9 @@ # - Timer(n, prescaler): zero-cost timer ZCA, prescaler folded at compile time # - Timer.irq(handler): registers handler at the OVF vector via compile_isr; # no @interrupt decorator or manual TIMSK/SEI writes needed -# - GPIOR0 atomic flag pattern: ISR sets bit, main loop clears and acts +# - ISR<->main signaling through a plain module global: detected as +# ISR-shared (volatile semantics) and auto-promoted to GPIOR0, so the +# flag accesses compile to single-cycle OUT/IN -- no manual GPIOR idiom # # Hardware: Arduino Uno # - LED on PB5 (Arduino pin 13, built-in) -- blinks every ~1 second @@ -14,15 +16,19 @@ # overflow period = 65536 * 256 / 16_000_000 ~= 1.049 s # from pymcu.types import uint8 -from pymcu.chips.atmega328p import GPIOR0 from pymcu.hal.gpio import Pin from pymcu.hal.uart import UART from pymcu.hal.timer import Timer +# Set by the ISR, polled and cleared by main. ISR-shared -> auto-promoted +# to GPIOR0; starts at 0 on reset. +tick: uint8 = 0 + def on_overflow(): - # Minimal ISR: set atomic flag using SBI (no register corruption) - GPIOR0[0] = 1 + # Minimal ISR: set the shared flag (compiles to OUT on GPIOR0) + global tick + tick = 1 def main(): @@ -34,12 +40,11 @@ def main(): timer = Timer(1, 256) timer.irq(on_overflow) - GPIOR0[0] = 0 uart.println("TIMER1 IRQ BLINK") while True: - if GPIOR0[0] == 1: - GPIOR0[0] = 0 + if tick == 1: + tick = 0 led.toggle() uart.write('T') uart.write('\n') diff --git a/src/csharp/lib/Targets/AVR/AvrCodeGen.cs b/src/csharp/lib/Targets/AVR/AvrCodeGen.cs index 980cce48..71048d14 100644 --- a/src/csharp/lib/Targets/AVR/AvrCodeGen.cs +++ b/src/csharp/lib/Targets/AVR/AvrCodeGen.cs @@ -43,10 +43,45 @@ public class AvrCodeGen(DeviceConfig cfg) : CodeGen private Function? _currentFunction; private int _maxStaticUsage; // total static SRAM used by StackAllocator; set in Compile() private int _bssSize; + private int _argSpillBytes; // bytes of the fixed SRAM region for >R16..R25 overflow arguments private bool _needsGc; // mirrors program.NeedsGc for use in CompileFunction + // Divmod fusion (per function): __div16 already produces both quotient (R24:R25) + // and remainder (R26:R27). When the same dividend is divided AND mod'd by the same + // divisor in one basic block (the decimal-print digit loop: r = v % 10; v = v // 10), + // the pair collapses to a single __div16 call. _divModFuse maps the primary Binary + // (where the call is emitted) to the two result destinations; _divModSkip holds the + // secondary Binary, which emits nothing. Reference identity, rebuilt each function. + private Dictionary _divModFuse = + new(ReferenceEqualityComparer.Instance); + private HashSet _divModSkip = new(ReferenceEqualityComparer.Instance); + + // Byte-pack fusion (per function): 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+shift+add lowering spends ~10 instructions on it. Every 16-bit + // sensor read (ADC ADCH/ADCL, BMP280, DHT, ...) hits this. _bytePackFuse maps the + // combining Binary (Add/BitOr, where the packed value is emitted) to the (hi, lo) + // byte sources; _bytePackSkip holds the now-dead `hi << 8` shift. + private Dictionary _bytePackFuse = + new(ReferenceEqualityComparer.Instance); + private HashSet _bytePackSkip = new(ReferenceEqualityComparer.Instance); + private string MakeLabel(string prefix = ".L") => $"{prefix}_{_labelCounter++}"; private static string GetHighReg(string reg) => "R" + (int.Parse(reg[1..]) + 1); + + // If the value is resident in an allocated home register (named-var R4-R15 pool or + // R16/R17 temporary pool), return that register's low byte; otherwise null. Used by + // reg-reg ALU emitters to consume the operand directly instead of staging it through + // R18. Reading a register as the second ALU operand never clobbers it, so this is + // safe for both pools. + private string? OperandHomeReg(Val v) + { + var name = v switch { Variable x => x.Name, Temporary t => t.Name, _ => null }; + if (name == null) return null; + if (_regLayout.TryGetValue(name, out var r)) return r; + if (_tmpRegLayout.TryGetValue(name, out var r2)) return r2; + return null; + } private void Emit(string m) => _assembly.Add(AvrAsmLine.MakeInstruction(m)); private void Emit(string m, string o1) => _assembly.Add(AvrAsmLine.MakeInstruction(m, o1)); private void Emit(string m, string o1, string o2) => _assembly.Add(AvrAsmLine.MakeInstruction(m, o1, o2)); @@ -316,6 +351,16 @@ private static bool IsSignedComparison(Val src1, Val src2) return false; } + // Picks the division/modulo runtime routine. When `signed` is set, the floor + // variant (__divs*/__mods*, Python semantics: quotient floors toward -inf and + // the remainder takes the sign of the divisor) is used; otherwise the unsigned + // core (__div*/__mod*). `wantMod` selects the modulo entry point. + private static string DivModRoutine(bool wantMod, bool is32, bool is16, bool signed) + { + string sz = is32 ? "32" : is16 ? "16" : "8"; + return $"__{(wantMod ? "mod" : "div")}{(signed ? "s" : "")}{sz}"; + } + private void EmitBranch(string cond, string target) { var inv = new Dictionary @@ -331,6 +376,121 @@ private void EmitBranch(string cond, string target) EmitLabel(skip); } + // Integer-argument register assignment. Each argument takes a 2-register slot (1- or 2-byte + // values) or a 4-register block (32-bit), allocated from R25 downward so a wide argument no + // longer collides with a previous one (the old fixed [R24,R22,R20,R18] spacing assumed every + // arg was <= 2 bytes, so `f(u8, u32)` loaded the u32 into R22:R23:R24:R25 and clobbered arg0). + // A 32-bit FIRST arg keeps the R24-anchored PyMCU layout (byte0=R24, byte2=R22); a lower 32-bit + // arg is contiguous from its base. Returns the base register (as passed to LoadIntoReg) per arg. + // Caller and callee must use this identically. Floats keep their own special-casing. + private static List ArgBaseRegs(IReadOnlyList sizes) + { + var bases = new List(sizes.Count); + int top = 25; + foreach (int sz in sizes) + { + int slots = sz >= 4 ? 4 : 2; + int low = top - slots + 1; + int baseNum = slots == 4 && top == 25 ? 24 : low; + // Argument registers must be R16..R25: loading an immediate (LDI/SUBI/...) requires a + // high register, and arg values include constants. Assigning a base below R16 (which + // avr-gcc reaches for many args) makes the assembler reject "register above 15 required". + // Reject rather than silently miscompile (the old code capped at 4 and dropped the rest). + if (baseNum < 16) + throw new Exception( + "too many/wide arguments to pass in registers (they must fit R16..R25); " + + "use fewer parameters, pack them into a struct/array, or mark the function @inline"); + bases.Add("R" + baseNum); + top = low - 1; + } + + return bases; + } + + // An argument's home: either a register (R16..R25) or a byte offset into the fixed SRAM + // overflow region `_arg_spill`. Register args use the same R16..R25 assignment as ArgBaseRegs; + // once an argument no longer fits there, it and every later argument spill to SRAM. Spilling + // (rather than reaching into R8..R15) preserves the register allocator's invariant that the + // R2..R15 home pool is never touched by the calling convention, so live variables survive calls. + private readonly record struct ArgLoc(bool IsReg, string Reg, int SpillOffset, int Size); + + private static List AssignArgLocations(IReadOnlyList sizes, out int spillBytes) + { + var locs = new List(sizes.Count); + int top = 25; + int spill = 0; + bool spilling = false; + foreach (int sz in sizes) + { + int slots = sz >= 4 ? 4 : 2; + if (!spilling) + { + int low = top - slots + 1; + int baseNum = slots == 4 && top == 25 ? 24 : low; + if (baseNum >= 16) + { + locs.Add(new ArgLoc(true, "R" + baseNum, 0, sz)); + top = low - 1; + continue; + } + spilling = true; // out of R16..R25 — this and all later args go to SRAM + } + // A spilled arg wider than 2 bytes would need 4 scratch registers to stage through; + // that is not worth the complexity for an over-budget wide argument. Reject clearly. + if (sz > 2) + throw new Exception( + "too many arguments: an argument wider than 2 bytes overflows the register file; " + + "reorder so wide arguments come first, use fewer parameters, or mark it @inline"); + locs.Add(new ArgLoc(false, "", spill, sz)); + spill += sz; + } + spillBytes = spill; + return locs; + } + + // Stage a spilled argument through R26:R27 (X — call-clobbered, never an arg or home register) + // and store it to the SRAM overflow region. Spilled args are <= 2 bytes (see AssignArgLocations). + private void StoreArgToSpill(Val arg, int offset, DataType type) + { + int sz = type.SizeOf(); + LoadIntoReg(arg, "R26", type); + Emit("STS", $"_arg_spill + {offset}", "R26"); + if (sz >= 2) Emit("STS", $"_arg_spill + {offset + 1}", "R27"); + } + + // Fill the high bytes [srcSize..size-1] of a widened value once its real low bytes are loaded. + // zeroExt clears them; signExt replicates the sign of the source's highest real byte (the SBC + // idiom yields 0xFF when negative, 0x00 otherwise). Covers all widths: int8->int16/int32 and + // int16->int32 (the latter two were previously left as garbage — only size==2 was handled). + private void EmitWidenFill(bool signExt, bool zeroExt, int srcSize, int size, + string reg, string regH, string regB2, string regB3) + { + if (zeroExt) + { + if (size >= 2 && srcSize < 2) Emit("CLR", regH); + if (size == 4 && srcSize < 4) { Emit("CLR", regB2); Emit("CLR", regB3); } + return; + } + if (!signExt) return; + + if (srcSize >= 2) + { + // srcSize==2 -> size==4: sign-fill bytes 2,3 from the real high byte (regH). + Emit("MOV", regB2, regH); + Emit("LSL", regB2); + Emit("SBC", regB2, regB2); + Emit("MOV", regB3, regB2); + } + else + { + // srcSize==1: sign-fill byte1 (and bytes 2,3 when widening to 32-bit) from reg. + Emit("MOV", regH, reg); + Emit("LSL", regH); + Emit("SBC", regH, regH); + if (size == 4) { Emit("MOV", regB2, regH); Emit("MOV", regB3, regH); } + } + } + private void LoadIntoReg(Val val, string reg, DataType type = DataType.UINT8) { int size = type.SizeOf(); @@ -375,6 +535,16 @@ private void LoadIntoReg(Val val, string reg, DataType type = DataType.UINT8) if (size >= 2) Emit("LDI", regH, $"hi8(gs({fr.FunctionName}))"); return; } + case FlashStrAddr fs: + { + // 16-bit flash BYTE address of an interned string (FlashData label is + // "__flash_" + name, same convention as ArrayLoadFlash). Loaded byte-wise + // via LPM by FlashLoadPtr, so this is a byte address (no gs()/word scaling). + string fsLabel = "__flash_" + fs.Name.Replace('.', '_'); + Emit("LDI", reg, $"lo8({fsLabel})"); + if (size >= 2) Emit("LDI", regH, $"hi8({fsLabel})"); + return; + } case MemoryAddress mem: { if (mem.Address is >= 0x20 and <= 0x5F) @@ -401,62 +571,31 @@ private void LoadIntoReg(Val val, string reg, DataType type = DataType.UINT8) if (!string.IsNullOrEmpty(name) && _regLayout.TryGetValue(name, out var srcReg)) { DataType sourceType = GetValType(val); - bool needSignExt = size == 2 && sourceType.SizeOf() == 1 && IsSignedType(sourceType); - bool needZeroExt = size > sourceType.SizeOf() && !IsSignedType(sourceType) && !needSignExt; + int srcSize = sourceType.SizeOf(); + bool signExt = size > srcSize && IsSignedType(sourceType); + bool zeroExt = size > srcSize && !IsSignedType(sourceType); + int srcN = int.Parse(srcReg[1..]); if (srcReg != reg) Emit("MOV", reg, srcReg); - else if (!needSignExt && !needZeroExt && srcReg == reg) - { - // Source already in target reg; still need to populate high bytes if multi-byte - if (size >= 2) Emit("MOV", regH, GetHighReg(srcReg)); - if (size == 4) { Emit("MOV", regB2, $"R{int.Parse(srcReg[1..]) + 2}"); Emit("MOV", regB3, $"R{int.Parse(srcReg[1..]) + 3}"); } - return; - } - - if (needZeroExt) - { - if (size >= 2) Emit("CLR", regH); - if (size == 4) { Emit("CLR", regB2); Emit("CLR", regB3); } - } - else - { - if (size >= 2 && !needSignExt) Emit("MOV", regH, GetHighReg(srcReg)); - if (size == 4) { Emit("MOV", regB2, $"R{int.Parse(srcReg[1..]) + 2}"); Emit("MOV", regB3, $"R{int.Parse(srcReg[1..]) + 3}"); } - } - - if (needSignExt) - { - Emit("MOV", regH, reg); - Emit("LSL", regH); - Emit("SBC", regH, regH); - } + // Load the source's real bytes that the target needs (the rest are filled below). + if (size >= 2 && srcSize >= 2) Emit("MOV", regH, GetHighReg(srcReg)); + if (size == 4 && srcSize >= 4) { Emit("MOV", regB2, $"R{srcN + 2}"); Emit("MOV", regB3, $"R{srcN + 3}"); } + EmitWidenFill(signExt, zeroExt, srcSize, size, reg, regH, regB2, regB3); return; } if (!string.IsNullOrEmpty(name) && _tmpRegLayout.TryGetValue(name, out var tmpReg)) { DataType sourceType = GetValType(val); - bool needSignExt = size == 2 && sourceType.SizeOf() == 1 && IsSignedType(sourceType); - bool needZeroExt = size > sourceType.SizeOf() && !IsSignedType(sourceType) && !needSignExt; + int srcSize = sourceType.SizeOf(); + bool signExt = size > srcSize && IsSignedType(sourceType); + bool zeroExt = size > srcSize && !IsSignedType(sourceType); + int tmpN = int.Parse(tmpReg[1..]); if (tmpReg != reg) Emit("MOV", reg, tmpReg); - if (needZeroExt) - { - if (size >= 2) Emit("CLR", regH); - if (size == 4) { Emit("CLR", regB2); Emit("CLR", regB3); } - } - else - { - if (size >= 2 && !needSignExt) Emit("MOV", regH, GetHighReg(tmpReg)); - if (size == 4) { Emit("MOV", regB2, $"R{int.Parse(tmpReg[1..]) + 2}"); Emit("MOV", regB3, $"R{int.Parse(tmpReg[1..]) + 3}"); } - } - - if (needSignExt) - { - Emit("MOV", regH, reg); - Emit("LSL", regH); - Emit("SBC", regH, regH); - } + if (size >= 2 && srcSize >= 2) Emit("MOV", regH, GetHighReg(tmpReg)); + if (size == 4 && srcSize >= 4) { Emit("MOV", regB2, $"R{tmpN + 2}"); Emit("MOV", regB3, $"R{tmpN + 3}"); } + EmitWidenFill(signExt, zeroExt, srcSize, size, reg, regH, regB2, regB3); return; } @@ -464,72 +603,38 @@ private void LoadIntoReg(Val val, string reg, DataType type = DataType.UINT8) { bool nearY = offset + (size - 1) < 64; DataType sourceType = GetValType(val); - bool needSignExt = size == 2 && sourceType.SizeOf() == 1 && IsSignedType(sourceType); - bool needZeroExt = size > sourceType.SizeOf() && !IsSignedType(sourceType) && !needSignExt; + int srcSize = sourceType.SizeOf(); + bool signExt = size > srcSize && IsSignedType(sourceType); + bool zeroExt = size > srcSize && !IsSignedType(sourceType); if (nearY) { Emit("LDD", reg, $"Y+{offset}"); - if (needZeroExt) - { - if (size >= 2) Emit("CLR", regH); - if (size == 4) { Emit("CLR", regB2); Emit("CLR", regB3); } - } - else - { - if (size >= 2 && !needSignExt) Emit("LDD", regH, $"Y+{offset + 1}"); - if (size == 4) { Emit("LDD", regB2, $"Y+{offset + 2}"); Emit("LDD", regB3, $"Y+{offset + 3}"); } - } + if (size >= 2 && srcSize >= 2) Emit("LDD", regH, $"Y+{offset + 1}"); + if (size == 4 && srcSize >= 4) { Emit("LDD", regB2, $"Y+{offset + 2}"); Emit("LDD", regB3, $"Y+{offset + 3}"); } } else { var abs = 0x0100 + offset; Emit("LDS", reg, $"0x{abs:X4}"); - if (needZeroExt) - { - if (size >= 2) Emit("CLR", regH); - if (size == 4) { Emit("CLR", regB2); Emit("CLR", regB3); } - } - else - { - if (size >= 2 && !needSignExt) Emit("LDS", regH, $"0x{abs + 1:X4}"); - if (size == 4) { Emit("LDS", regB2, $"0x{abs + 2:X4}"); Emit("LDS", regB3, $"0x{abs + 3:X4}"); } - } - } - - if (needSignExt) - { - Emit("MOV", regH, reg); - Emit("LSL", regH); - Emit("SBC", regH, regH); + if (size >= 2 && srcSize >= 2) Emit("LDS", regH, $"0x{abs + 1:X4}"); + if (size == 4 && srcSize >= 4) { Emit("LDS", regB2, $"0x{abs + 2:X4}"); Emit("LDS", regB3, $"0x{abs + 3:X4}"); } } + EmitWidenFill(signExt, zeroExt, srcSize, size, reg, regH, regB2, regB3); return; } var addr = ResolveAddress(val); if (string.IsNullOrEmpty(addr)) return; DataType srcType = GetValType(val); - bool signExt = size == 2 && srcType.SizeOf() == 1 && IsSignedType(srcType); - bool zeroExt = size > srcType.SizeOf() && !IsSignedType(srcType) && !signExt; + int srcSizeA = srcType.SizeOf(); + bool signExtA = size > srcSizeA && IsSignedType(srcType); + bool zeroExtA = size > srcSizeA && !IsSignedType(srcType); Emit("LDS", reg, addr); - if (zeroExt) - { - if (size >= 2) Emit("CLR", regH); - if (size == 4) { Emit("CLR", regB2); Emit("CLR", regB3); } - } - else - { - if (size >= 2 && !signExt) Emit("LDS", regH, addr + "+1"); - if (size == 4) { Emit("LDS", regB2, addr + "+2"); Emit("LDS", regB3, addr + "+3"); } - } - - if (signExt) - { - Emit("MOV", regH, reg); - Emit("LSL", regH); - Emit("SBC", regH, regH); - } + if (size >= 2 && srcSizeA >= 2) Emit("LDS", regH, addr + "+1"); + if (size == 4 && srcSizeA >= 4) { Emit("LDS", regB2, addr + "+2"); Emit("LDS", regB3, addr + "+3"); } + EmitWidenFill(signExtA, zeroExtA, srcSizeA, size, reg, regH, regB2, regB3); } private void StoreRegInto(string reg, Val val, DataType type = DataType.UINT8) @@ -602,6 +707,11 @@ public override void Compile(ProgramIR program, TextWriter output) _allTmpRegNames.Clear(); _labelCounter = 0; + // Promote ISR-shared single-byte globals to GPIORn before any allocation + // pass runs: promoted names become MemoryAddress operands, so the stack + // and register allocators never see them and BSS shrinks accordingly. + var gpiorPromotions = AvrGpiorPromotion.Apply(program, cfg); + var allocator = new StackAllocator(); var (offsets, maxStack) = allocator.Allocate(program); _stackLayout = offsets; @@ -641,8 +751,28 @@ public override void Compile(ProgramIR program, TextWriter output) _functionParamSizes[func.Name] = sizes; } + // Size the SRAM overflow region: the most bytes any direct call passes beyond R16..R25. + _argSpillBytes = 0; + foreach (var func in program.Functions) + foreach (var instr in func.Body) + { + if (instr is not Call cl) continue; + var sizes = _functionParamSizes.TryGetValue(cl.FunctionName, out var ps) && ps.Count >= cl.Args.Count + ? ps + : cl.Args.Select(a => GetValType(a).SizeOf()).ToList(); + try + { + AssignArgLocations(sizes.Take(cl.Args.Count).ToList(), out int sb); + if (sb > _argSpillBytes) _argSpillBytes = sb; + } + catch { /* over-budget call; the per-call emit reports it with the right diagnostic */ } + } + EmitComment("Generated by pymcuc for " + cfg.Chip); + foreach (var (gName, gAddr) in gpiorPromotions.OrderBy(kv => kv.Value)) + EmitComment($"volatile '{gName}' -> GPIOR @ 0x{gAddr:X2} (ISR-shared, auto-promoted)"); + foreach (var sym in program.ExternSymbols) EmitRaw(".extern " + sym); if (program.ExternSymbols.Count > 0) EmitRaw(""); @@ -661,6 +791,12 @@ public override void Compile(ProgramIR program, TextWriter output) if (_bssSize > 0) EmitRaw($".equ _bss_end, _stack_base + {_bssSize}"); + // Fixed SRAM region for arguments that overflow R16..R25, placed just above the static + // frame (the hardware stack grows down from RAMEND, far above). A callee reads its spilled + // args from here at entry, before any nested call, so a single shared region is safe. + if (_argSpillBytes > 0) + EmitRaw($".equ _arg_spill, _stack_base + {_maxStaticUsage}"); + if (program.NeedsGc) EmitGcSramLayout(); @@ -670,6 +806,17 @@ public override void Compile(ProgramIR program, TextWriter output) var isrMap = new SortedDictionary(); foreach (var func in program.Functions.Where(func => func.IsInterrupt)) { + // The vector is a byte address into the table emitted below (vec*2 for vec in + // 1..25, i.e. the even addresses 0x02..0x32). A vector outside that range — or + // an odd one — would never match a table slot, so the handler was silently + // dropped (it ran as dead code, the vector jumped to __bad_interrupt). Reject it. + if (func.InterruptVector < 2 || func.InterruptVector > 50 || (func.InterruptVector & 1) != 0) + { + throw new Exception( + $"@interrupt vector 0x{func.InterruptVector:X} on '{func.Name}' is out of range; " + + "expected an even vector address in 0x02..0x32 (e.g. 0x04 for INT0, 0x20 for TIMER0_OVF)"); + } + // Add duplicate ISR check that was missing in the C# port if (!isrMap.TryAdd(func.InterruptVector, func)) { @@ -795,11 +942,11 @@ void AddRef(string name) EmitFlashArrayPool(output); if (program.NeedsGc) EmitGcRuntime(output); - // Emit the exception runtime when either the SJLJ model (setjmp) is used - // OR when the T-flag model calls __pymcu_unhandled_exn for unmatched catches. - bool needsExnRuntime = program.ExternSymbols.Contains("setjmp") - || program.Functions.Any(f => - f.Body.OfType().Any(c => c.FunctionName == "__pymcu_unhandled_exn")); + // Emit the exception runtime when the T-flag model calls __pymcu_unhandled_exn + // for an unmatched catch. + bool needsExnRuntime = program.Functions.Any(f => + f.Body.OfType().Any(c => c.FunctionName == "__pymcu_unhandled_exn") + || f.Body.OfType().Any(b => b.ErrorLabel == "__pymcu_unhandled_exn")); if (needsExnRuntime) EmitExnRuntime(output, _usedExnCodes, cfg.Chip); WriteSymbolsIfRequested(optimized, program); WriteLineMapIfRequested(optimized); @@ -811,16 +958,17 @@ public override void EmitContextSave() EmitComment("ISR prologue -- save context"); // R0 is clobbered by every MUL; R1 is the zero register assumed by SBC/ADC after MUL. // avr-gcc saves both in every ISR to prevent corruption of the interrupted context. - Emit("PUSH", "R0"); - Emit("PUSH", "R1"); - Emit("PUSH", "R16"); - Emit("PUSH", "R17"); - Emit("PUSH", "R18"); - Emit("PUSH", "R19"); - Emit("PUSH", "R24"); - Emit("PUSH", "R25"); - Emit("PUSH", "R26"); - Emit("PUSH", "R27"); + // Save the full caller-clobbered range the ISR body — or any function it calls — may + // use as scratch. The earlier fixed set omitted R20/R21 (the 16-bit MUL accumulator), + // R22/R23 (32-bit math and the 3rd/4th argument pair), and R30/R31 (the Z pointer for + // array/flash access). An ISR using any of those silently corrupted the interrupted + // code's value held there (e.g. a uint16 multiply in the ISR clobbered main's R20:R21). + // R28/R29 (Y, the frame pointer) is used only as a base, never reassigned, so it is + // preserved without saving; R2-R15 are the globally-unique named-var pool (an ISR only + // touches its OWN names there, never the interrupted function's). + foreach (var r in new[] { "R0", "R1", "R16", "R17", "R18", "R19", "R20", "R21", + "R22", "R23", "R24", "R25", "R26", "R27", "R30", "R31" }) + Emit("PUSH", r); Emit("IN", "R16", "0x3F"); Emit("PUSH", "R16"); // Ensure R1 == 0 inside the ISR body (MUL may have left it non-zero in main). @@ -832,20 +980,81 @@ public override void EmitContextRestore() EmitComment("ISR epilogue -- restore context"); Emit("POP", "R16"); Emit("OUT", "0x3F", "R16"); - Emit("POP", "R27"); - Emit("POP", "R26"); - Emit("POP", "R25"); - Emit("POP", "R24"); - Emit("POP", "R19"); - Emit("POP", "R18"); - Emit("POP", "R17"); - Emit("POP", "R16"); - Emit("POP", "R1"); - Emit("POP", "R0"); + foreach (var r in new[] { "R31", "R30", "R27", "R26", "R25", "R24", "R23", "R22", + "R21", "R20", "R19", "R18", "R17", "R16", "R1", "R0" }) + Emit("POP", r); } public override void EmitInterruptReturn() => Emit("RETI"); + // Parse "R12" -> 12; pointer tokens "X"/"Y"/"Z" (and their +/- forms) -> the pair's low reg + // (26/28/30); else -1. Used by the ISR-save trimmer to learn which registers a body touches. + private static int ParseRegToken(string s) + { + if (string.IsNullOrEmpty(s)) return -1; + char c0 = s[0]; + if (c0 == 'R' && int.TryParse(s.AsSpan(1), out int n)) return n; + if (s.Contains('X')) return 26; + if (s.Contains('Z')) return 30; + if (s.Contains('Y')) return 28; + return -1; + } + + private static void AddTouched(string op, HashSet set) + { + int r = ParseRegToken(op); + if (r < 0) return; + set.Add(r); + // Pointer tokens and (handled at call site) word ops occupy a register pair. + if (op.Length > 0 && (op[0] == 'X' || op[0] == 'Y' || op[0] == 'Z')) set.Add(r + 1); + } + + // Shrinks the conservative ISR context save (R0,R1,R16-R27,R30,R31,SREG) to the registers + // the handler body actually uses. Purely subtractive: it removes a PUSH and the matching + // POP(s) only for a register the body provably never touches, so it can never drop a + // needed save. R1 and R16 are always kept (R16 backs the SREG save, R1 is the zero + // register the prologue clears); if the body makes ANY call, the full set is kept (the + // callee may clobber anything caller-saved). + private void TrimIsrContextSave(int start, int end) + { + var used = new HashSet(); + bool hasCall = false, hasMul = false; + for (int i = start; i < end; i++) + { + var ln = _assembly[i]; + if (ln.Type != AvrAsmLine.LineType.Instruction) continue; + string m = ln.Mnemonic; + if (m is "PUSH" or "POP") continue; // context-save itself + if ((m == "IN" || m == "OUT") && (ln.Op1 == "0x3F" || ln.Op2 == "0x3F")) continue; // SREG save + if (m is "CALL" or "RCALL" or "ICALL" or "EICALL") hasCall = true; + if (m.StartsWith("MUL") || m.StartsWith("FMUL")) hasMul = true; + AddTouched(ln.Op1, used); + AddTouched(ln.Op2, used); + // Word ops touch the high half of their register pair implicitly. + if (m is "MOVW" or "ADIW" or "SBIW") + { + int a = ParseRegToken(ln.Op1); if (a >= 0) used.Add(a + 1); + if (m == "MOVW") { int b = ParseRegToken(ln.Op2); if (b >= 0) used.Add(b + 1); } + } + } + if (hasMul) { used.Add(0); used.Add(1); } + if (hasCall) return; // a callee may clobber any caller-saved register — keep the full save + + // Trimmable = the caller-saved registers the save covers, minus the always-kept R1/R16. + var drop = new HashSet(); + foreach (var r in new[] { 0, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 30, 31 }) + if (!used.Contains(r)) drop.Add(r); + if (drop.Count == 0) return; + + for (int i = start; i < end; i++) + { + var ln = _assembly[i]; + if (ln.Type == AvrAsmLine.LineType.Instruction && ln.Mnemonic is "PUSH" or "POP" + && ParseRegToken(ln.Op1) is int rr && drop.Contains(rr)) + _assembly[i] = AvrAsmLine.MakeEmpty(); + } + } + private void CompileFunction(Function func) { _currentFunction = func; @@ -853,6 +1062,10 @@ private void CompileFunction(Function func) foreach (var (name, _) in _tmpRegLayout) _allTmpRegNames.Add(name); + DetectDivModFusions(func); + DetectBytePackFusions(func); + + int funcAsmStart = _assembly.Count; EmitLabel(func.Name); if (func.IsInterrupt && !func.IsNaked) EmitContextSave(); @@ -889,17 +1102,46 @@ private void CompileFunction(Function func) if (!func.IsInterrupt && func.Name != "main" && func.Params.Count > 0) { - string[] argRegs = ["R24", "R22", "R20", "R18"]; - for (var k = 0; k < func.Params.Count && k < 4; k++) + var paramSizes = func.Params + .Select(p => _varSizes.TryGetValue(p, out var psz0) ? psz0 : 1).ToList(); + var argLocs = AssignArgLocations(paramSizes, out _); + for (var k = 0; k < func.Params.Count; k++) { var pname = func.Params[k]; bool p16 = _varSizes.TryGetValue(pname, out int psz) && psz == 2; bool p32 = _varSizes.TryGetValue(pname, out int psz32) && psz32 == 4; bool pFloat = p32 && _varIsFloat.Contains(pname); + + // Spilled parameter (<= 2 bytes, see AssignArgLocations): read it from the SRAM + // overflow region into its home register or stack slot. Staged through R26 (free + // at function entry). Floats/uint32 never spill, so only 1-2 byte widths appear here. + if (!argLocs[k].IsReg) + { + int spillOff = argLocs[k].SpillOffset; + if (_regLayout.TryGetValue(pname, out var sr)) + { + Emit("LDS", sr, $"_arg_spill + {spillOff}"); + if (p16) Emit("LDS", GetHighReg(sr), $"_arg_spill + {spillOff + 1}"); + } + else if (_stackLayout.TryGetValue(pname, out int soff)) + { + if (!IsVariableReadInBody(pname, func.Body)) continue; + bool nearY2 = soff + (p16 ? 1 : 0) < 64; + Emit("LDS", "R26", $"_arg_spill + {spillOff}"); + if (nearY2) Emit("STD", $"Y+{soff}", "R26"); else Emit("STS", $"0x{0x0100 + soff:X4}", "R26"); + if (p16) + { + Emit("LDS", "R26", $"_arg_spill + {spillOff + 1}"); + if (nearY2) Emit("STD", $"Y+{soff + 1}", "R26"); else Emit("STS", $"0x{0x0100 + soff + 1:X4}", "R26"); + } + } + continue; + } + // Float ABI: first float arg is in R22(byte0):R23(byte1):R24(byte2):R25(byte3). // uint32 ABI: first arg is in R24(byte0):R25(byte1):R22(byte2):R23(byte3). // For floats, use R22 as base; for uint32, use R24 as base. - string aR = pFloat && k == 0 ? "R22" : argRegs[k]; + string aR = pFloat && k == 0 ? "R22" : argLocs[k].Reg; if (_regLayout.TryGetValue(pname, out var r)) { if (aR != r) Emit("MOV", r, aR); @@ -984,11 +1226,56 @@ private void CompileFunction(Function func) } } + // A region whose result is produced DIRECTLY by a trailing Call (a "passthrough", e.g. + // `def run_it(self): return self.hook()`) must NOT be outlined: the callee leaves its + // result in the return registers (R24:R25), the region's result temp coalesces with them, + // and the outliner emits no move -- but the outlined call site reads the result from the + // temp's own register (R16:R17), which the subroutine never writes => garbage. Keeping such + // a region inline lets the call result be consumed in the caller's own allocation context. + bool RegionIsCallPassthrough(int start, int end) + { + for (int k = end - 1; k > start; k--) // last real instruction before the end marker + { + var si = func.Body[k]; + if (si is DebugLine or InlineExpansionMarker) continue; + return si is Call { Dst: not NoneVal }; + } + return false; + } + + // The outliner emits ONE occurrence as the subroutine and RCALLs all of them, so every + // occurrence must be byte-identical -- otherwise an occurrence that differs (e.g. a force- + // inlined method whose result temp lands in a different stack slot per call site) reads its + // result from a location the shared body never writes (deep-inheritance go() called twice + // returned the right value once, then 0). Only share occurrences that compare equal. + bool RangesIdentical(List<(int start, int end)> ranges) + { + if (ranges.Count <= 1) return true; + List Body(int s, int e) + { + var b = new List(); + for (int k = s + 1; k < e; k++) + if (func.Body[k] is not DebugLine) b.Add(func.Body[k]); + return b; + } + var first = Body(ranges[0].start, ranges[0].end); + for (int r = 1; r < ranges.Count; r++) + { + var other = Body(ranges[r].start, ranges[r].end); + if (other.Count != first.Count) return false; + for (int j = 0; j < first.Count; j++) + if (!Equals(first[j], other[j])) return false; + } + return true; + } + // Map funcName → subroutine label; collect subroutine body ranges. var outlinedLabels = new Dictionary(); var pendingSubroutines = new List<(string label, int start, int end)>(); foreach (var (fname, ranges) in inlineGroups) { + if (ranges.Any(r => RegionIsCallPassthrough(r.start, r.end))) continue; // keep inline + if (!RangesIdentical(ranges)) continue; // keep inline var label = MakeLabel("_pymcu_outline"); outlinedLabels[fname] = label; // Body range: [ranges[0].start + 1, ranges[0].end) (exclusive of markers) @@ -996,13 +1283,19 @@ private void CompileFunction(Function func) } // --- Main compilation loop with outlining --- + // A marked region is either OUTLINED (replaced by an RCALL here, body emitted once as a + // subroutine) or kept INLINE (body emitted here). The skip stack tracks, per open marker, + // whether its body is being skipped (because it was outlined) or emitted inline. A region + // NOT in outlinedLabels must be emitted inline -- the old code unconditionally skipped + // every marked region, silently DROPPING any region we chose not to outline. bool emittedEpilogue = false; - int skipDepth = 0; + var skipStack = new Stack(); + bool Skipping() => skipStack.Count > 0 && skipStack.Peek(); foreach (var instr in func.Body) { if (func.IsInterrupt && !func.IsNaked && instr is Return) { - if (skipDepth == 0) + if (!Skipping()) { EmitContextRestore(); Emit("RETI"); @@ -1015,18 +1308,24 @@ private void CompileFunction(Function func) { if (!iem.IsEnd) { - if (skipDepth == 0 && outlinedLabels.TryGetValue(iem.FuncName, out var rcallLabel)) + if (!Skipping() && outlinedLabels.TryGetValue(iem.FuncName, out var rcallLabel)) + { Emit("RCALL", rcallLabel); - skipDepth++; + skipStack.Push(true); // outlined: skip the inline body + } + else + { + skipStack.Push(Skipping()); // inline (false) or stay inside a skipped region + } } else { - if (skipDepth > 0) skipDepth--; + if (skipStack.Count > 0) skipStack.Pop(); } continue; } - if (skipDepth > 0) continue; + if (Skipping()) continue; CompileInstruction(instr); } @@ -1037,11 +1336,18 @@ private void CompileFunction(Function func) Emit("RETI"); } + // Trim the (conservative) ISR context save down to the registers this handler's body + // actually clobbers — recovers the size of trivial handlers without losing the + // correctness of the full save (it only ever REMOVES a push/pop of an unused register). + if (func.IsInterrupt && !func.IsNaked) + TrimIsrContextSave(funcAsmStart, _assembly.Count); + // --- Emit pending subroutines after the function body --- foreach (var (label, start, end) in pendingSubroutines) { EmitLabel(label); - int subSkip = 0; + var subSkip = new Stack(); + bool SubSkipping() => subSkip.Count > 0 && subSkip.Peek(); for (int i = start; i < end; i++) { var si = func.Body[i]; @@ -1049,17 +1355,17 @@ private void CompileFunction(Function func) { if (!sim.IsEnd) { - if (subSkip == 0 && outlinedLabels.TryGetValue(sim.FuncName, out var sl)) + if (!SubSkipping() && outlinedLabels.TryGetValue(sim.FuncName, out var sl)) + { Emit("RCALL", sl); - subSkip++; - } - else if (subSkip > 0) - { - subSkip--; + subSkip.Push(true); + } + else subSkip.Push(SubSkipping()); } + else if (subSkip.Count > 0) subSkip.Pop(); continue; } - if (subSkip > 0) continue; + if (SubSkipping()) continue; CompileInstruction(si); } Emit("RET"); @@ -1117,6 +1423,7 @@ private void CompileInstruction(Instruction instr) break; case ArrayLoad al: CompileArrayLoad(al); break; case ArrayLoadFlash alf: CompileArrayLoadFlash(alf); break; + case FlashLoadPtr flp: CompileFlashLoadPtr(flp); break; case FlashData fd: _flashArrayPool[fd.Name] = fd.Bytes; break; case ArrayStore ast: CompileArrayStore(ast); break; case BytearrayLoad bl: CompileBytearrayLoad(bl); break; @@ -1124,8 +1431,6 @@ private void CompileInstruction(Instruction instr) case GcAlloc ga: CompileGcAlloc(ga); break; case GcRoot gr: CompileGcRoot(gr); break; case GcUnroot gu: CompileGcUnroot(gu); break; - case TryBegin tb: CompileTryBegin(tb); break; - case RaiseExn re: CompileRaiseExn(re); break; case SignalError se: CompileSignalError(se); break; case SignalSuccess: CompileSignalSuccess(); break; case BranchOnError boe: CompileBranchOnError(boe); break; @@ -1395,12 +1700,64 @@ private void CompileCall(Call call) Emit("BRNE", label); return; } + // delay_us(const): same calibrated 6-cycle busy loop as delay_ms, sized in + // microseconds. Emitted inline so a constant micro-delay is a tight loop instead + // of a CALL to (or per-site inline of) the 12-NOP _delay_us_avr body. The matching + // DCE walk skips AddRef for this same const case, so the subroutine is only + // compiled when a runtime (non-constant) delay_us call needs it. + if ((call.FunctionName == "_delay_us_avr" || call.FunctionName.EndsWith("__delay_us_avr")) && call.Args.Count == 1 && call.Args[0] is Constant usConst) + { + ulong cycles = (ulong)usConst.Value * (cfg.Frequency / 1000000); + ulong loops = cycles / 6; + if (loops == 0) return; + + string label = $"_dly_L{_loopCounter++}"; + + Emit($"LDI", "R18", $"{(loops & 0xFF)}"); + Emit($"LDI", "R19", $"{((loops >> 8) & 0xFF)}"); + Emit($"LDI", "R20", $"{((loops >> 16) & 0xFF)}"); + Emit($"LDI", "R21", $"{((loops >> 24) & 0xFF)}"); + EmitLabel(label); + Emit($"SUBI", "R18", "1"); + Emit($"SBCI", "R19", "0"); + Emit($"SBCI", "R20", "0"); + Emit($"SBCI", "R21", "0"); + Emit("BRNE", label); + return; + } // Float arguments use R22:R25 (arg0) and R18:R21 (arg1) per float convention. - // Integer arguments use R24 (arg0), R22 (arg1), R20 (arg2), R18 (arg3). - string[] argRegs = ["R24", "R22", "R20", "R18"]; - for (var k = 0; k < call.Args.Count && k < 4; k++) + // Integer arguments are assigned by size (ArgBaseRegs) so a 32-bit arg gets a 4-register + // block and does not collide with the previous argument. + _functionParamSizes.TryGetValue(call.FunctionName, out var ps); + var argSizes = new List(call.Args.Count); + for (var k = 0; k < call.Args.Count; k++) + argSizes.Add(ps != null && k < ps.Count ? ps[k] : GetValType(call.Args[k]).SizeOf()); + var argLocs = AssignArgLocations(argSizes, out _); + + // Widen a constant/narrow arg to its declared parameter width (e.g. Constant(-1) for an + // int16 param) instead of the size inferred from the value's magnitude. + DataType ArgType(int k) { - var argType = GetValType(call.Args[k]); + var t = GetValType(call.Args[k]); + if (t != DataType.FLOAT + && _functionParamSizes.TryGetValue(call.FunctionName, out var pSizes) && k < pSizes.Count + && pSizes[k] >= 2 && t.SizeOf() < pSizes[k]) + t = pSizes[k] == 4 ? DataType.UINT32 : DataType.UINT16; + return t; + } + + // Pass 1: spilled args FIRST. Their source values (often temps in R16..R25) must be read + // before the register-arg loads below overwrite those registers — otherwise a spilled + // argument computed by a nested call read garbage (it came through as 0). + for (var k = 0; k < call.Args.Count; k++) + if (!argLocs[k].IsReg) + StoreArgToSpill(call.Args[k], argLocs[k].SpillOffset, ArgType(k)); + + // Pass 2: register args. + for (var k = 0; k < call.Args.Count; k++) + { + if (!argLocs[k].IsReg) continue; + var argType = ArgType(k); if (argType == DataType.FLOAT) { // Float arg0 → R22:R25; float arg1 → R18:R21 @@ -1416,17 +1773,7 @@ private void CompileCall(Call call) } continue; } - // Use the declared parameter size when available so that constants - // (e.g. Constant(-1) for an int16 param) are loaded with the correct - // width instead of the size inferred from the constant's magnitude. - if (_functionParamSizes.TryGetValue(call.FunctionName, out var paramSizes) && - k < paramSizes.Count) - { - int paramSize = paramSizes[k]; - if (paramSize >= 2 && argType.SizeOf() < paramSize) - argType = paramSize == 4 ? DataType.UINT32 : DataType.UINT16; - } - LoadIntoReg(call.Args[k], argRegs[k], argType); + LoadIntoReg(call.Args[k], argLocs[k].Reg, argType); } Emit("CALL", call.FunctionName); @@ -1442,9 +1789,10 @@ private void CompileCall(Call call) // The function address is loaded into Z (R30:R31) and ICALL is emitted. private void CompileIndirectCall(IndirectCall call) { - // Set up arguments into standard registers (same ABI as direct Call). - string[] argRegs = ["R24", "R22", "R20", "R18"]; - for (var k = 0; k < call.Args.Count && k < 4; k++) + // Set up arguments into standard registers (same ABI as direct Call): size-based base + // assignment so a 32-bit argument occupies a 4-register block without colliding. + var argRegs = ArgBaseRegs(call.Args.Select(a => GetValType(a).SizeOf()).ToList()); + for (var k = 0; k < call.Args.Count && k < argRegs.Count; k++) { var argType = GetValType(call.Args[k]); LoadIntoReg(call.Args[k], argRegs[k], argType); @@ -1481,8 +1829,6 @@ private void CompileIndirectCall(IndirectCall call) /// private void CompileVirtualCall(VirtualCall vc) { - string[] argRegs = ["R24", "R22", "R20", "R18"]; - // Load self pointer into Z (vptr is the first 2 bytes of the object in SRAM). string selfName = vc.Self.Name.Replace('.', '_'); if (_stackLayout.TryGetValue(selfName, out int selfOffset)) @@ -1515,10 +1861,11 @@ private void CompileVirtualCall(VirtualCall vc) Emit("LPM", "R31", "Z"); Emit("MOV", "R30", "R0"); - // Load self as arg 0, then the remaining arguments. + // Load self as arg 0, then the remaining arguments (size-based base assignment). var allArgs = new List { vc.Self }; allArgs.AddRange(vc.Args); - for (int k = 0; k < allArgs.Count && k < argRegs.Length; k++) + var argRegs = ArgBaseRegs(allArgs.Select(a => GetValType(a).SizeOf()).ToList()); + for (int k = 0; k < allArgs.Count && k < argRegs.Count; k++) { var argType = GetValType(allArgs[k]); LoadIntoReg(allArgs[k], argRegs[k], argType); @@ -1721,8 +2068,346 @@ private void CompileUnary(Unary u) StoreRegInto("R24", u.Dst, type); } + // Destination-targeted in-place codegen for 8-bit Add/Sub/And/Or/Xor where the result + // lives directly in dst's home register. This removes the codegen's "stage through R24, + // store home" round-trip: for `x = x + y` with x in a home register it collapses to a + // single `ADD Rx, Ry` (vs MOV/op/MOV). Returns false — emitting nothing — whenever the + // AVR register classes or operand shapes don't permit the in-place form, so the caller + // falls back to the existing staged path. Validation is done up front; emission only + // happens once the whole op is known to be feasible. + private bool TryCompileBinaryInPlace(Binary b) + { + DataType type = GetValType(b.Dst); + if (type is not (DataType.UINT8 or DataType.INT8 + or DataType.UINT16 or DataType.INT16)) return false; + int size = type.SizeOf(); // 1 or 2 + if (b.Op is not (IrBinOp.Add or IrBinOp.Sub or IrBinOp.BitAnd + or IrBinOp.BitOr or IrBinOp.BitXor)) return false; + + // dst must be register-resident; an SRAM dst gains nothing from in-place codegen. + string? rd = OperandHomeReg(b.Dst); + if (rd is null) return false; + bool rdUpper = int.Parse(rd[1..]) >= 16; // R16-R31 accept LDI/SUBI/ANDI/ORI + + // src1 must reach rd via MOV/LDD (any register) — not LDI, which is illegal for the + // low half. So only a same-width Variable/Temporary qualifies (constants/addresses out). + if (b.Src1 is not (Variable or Temporary)) return false; + if (GetValType(b.Src1).SizeOf() != size) return false; + + string? rs1 = OperandHomeReg(b.Src1); + string? rs2 = OperandHomeReg(b.Src2); + + // Restrict to the augmented form (dst == src1, i.e. src1 already in rd). For + // dst != src1 the staged path leaves the result in R24 where the next op can reuse + // it, so an in-place `MOV rd,src1; OP rd,...` tends to merely move the extra MOV + // rather than remove it. The augmented case is the unambiguous win: the staged path + // is always MOV/op/MOV, the in-place form collapses it to a single op on rd. + // (Measured 2026-06-14: relaxing this to dst != src1 regressed the example suite by + // ~100 bytes — the store-back MOV reappears as reload MOVs in chained expressions.) + if (rs1 != rd) return false; + + // src1 already lives in rd, so no load clobbers it; src2 in rd just means `x op x`. + + string mnem = b.Op switch + { + IrBinOp.Add => "ADD", + IrBinOp.Sub => "SUB", + IrBinOp.BitAnd => "AND", + IrBinOp.BitOr => "OR", + IrBinOp.BitXor => "EOR", + _ => "", + }; + + // --- Constant src2: must fit an immediate/inc-dec form valid for rd's class. --- + if (b.Src2 is Constant c) + { + // 16-bit immediates need SUBI/SBCI on an R16-R31 pair; register-pair homes here + // are always R4-R15 (the temporary pool is 8-bit), so fall back for 16-bit consts. + if (size != 1) return false; + if (b.Op is IrBinOp.BitXor) return false; // no immediate EOR form + int v = c.Value & 0xFF; + string? emit = b.Op switch + { + IrBinOp.Add when v == 1 => "INC", + IrBinOp.Add when v == 0xFF => "DEC", + IrBinOp.Sub when v == 1 => "DEC", + IrBinOp.Sub when v == 0xFF => "INC", + _ => null, + }; + if (emit is null && !rdUpper) return false; // immediate form needs R16-R31 + + LoadIntoReg(b.Src1, rd, type); + if (emit is not null) Emit(emit, rd); + else switch (b.Op) + { + case IrBinOp.Add: Emit("SUBI", rd, $"{(byte)(-v)}"); break; + case IrBinOp.Sub: Emit("SUBI", rd, $"{v}"); break; + case IrBinOp.BitAnd: Emit("ANDI", rd, $"{v}"); break; + case IrBinOp.BitOr: Emit("ORI", rd, $"{v}"); break; + } + return true; + } + + // --- Register/SRAM src2: byte-wise OP rd, . --- + if (b.Src2 is (Variable or Temporary) && GetValType(b.Src2).SizeOf() == size) + { + LoadIntoReg(b.Src1, rd, type); + string s2 = rs2 ?? "R18"; + if (rs2 is null) LoadIntoReg(b.Src2, "R18", type); + Emit(mnem, rd, s2); + if (size == 2) + { + // High byte carries the borrow/carry for Add/Sub (ADC/SBC). + string highMnem = b.Op switch + { + IrBinOp.Add => "ADC", + IrBinOp.Sub => "SBC", + _ => mnem, // AND/OR/EOR are byte-independent + }; + Emit(highMnem, GetHighReg(rd), GetHighReg(s2)); + } + return true; + } + + return false; + } + + // The operand size the Div/Mod lowering runs at: src1 may be wider than dst + // (e.g. uint16 // 10 -> uint8 reads the full 16-bit dividend), so the routine is + // chosen from the wider of the two. Mirrors the opType computed in CompileBinary. + private DataType DivModOpType(Binary b) + { + var s1 = GetValType(b.Src1); + var d = GetValType(b.Dst); + return s1.SizeOf() > d.SizeOf() ? s1 : d; + } + + // Finds same-operand divide/modulo pairs in a single basic block and records the + // fusion. Only unsigned 16-bit (the __div16 contract: quotient in R24:R25, remainder + // in R26:R27) is fused. The pair is valid only when, between the two ops, neither the + // dividend nor the divisor is rewritten and the second op's destination is never read + // or written (its result is materialized early, at the primary) — and no control flow + // intervenes, so the two run as one straight line. Mirror images (v % k before v // k + // and v // k before v % k) are both handled. + private void DetectDivModFusions(Function func) + { + _divModFuse.Clear(); + _divModSkip.Clear(); + var body = func.Body; + + bool Fusable(Binary x) => + x.Op is IrBinOp.Mod or IrBinOp.Div or IrBinOp.FloorDiv + && DivModOpType(x).SizeOf() is 1 or 2 // __div8 and __div16 both yield quot+rem + && !IsSignedComparison(x.Src1, x.Src2); // signed floor div/mod has its own routines + static bool IsMod(IrBinOp op) => op is IrBinOp.Mod; + static bool IsDiv(IrBinOp op) => op is IrBinOp.Div or IrBinOp.FloorDiv; + + for (int i = 0; i < body.Count; i++) + { + if (body[i] is not Binary p || !Fusable(p)) continue; + if (_divModSkip.Contains(p)) continue; // already a prior pair's secondary + + for (int j = i + 1; j < body.Count; j++) + { + var inst = body[j]; + + if (inst is Binary s && Fusable(s) + && ((IsMod(p.Op) && IsDiv(s.Op)) || (IsDiv(p.Op) && IsMod(s.Op))) + && Equals(s.Src1, p.Src1) && Equals(s.Src2, p.Src2) + && !Equals(p.Dst, s.Dst)) + { + // Verify the second op's destination is untouched in the window; its + // value is stored at the primary, so a read/write between would diverge. + bool clean = true; + for (int k = i + 1; k < j && clean; k++) + if (TouchesVal(body[k], s.Dst, read: true) + || TouchesVal(body[k], s.Dst, read: false)) + clean = false; + if (clean) + { + var (quot, rem) = IsMod(p.Op) ? (s.Dst, p.Dst) : (p.Dst, s.Dst); + _divModFuse[p] = (quot, rem); + _divModSkip.Add(s); + } + break; // this primary is resolved (paired, or a blocking divmod hit) + } + + // Only a constrained set of side-effect-free, control-flow-free instructions + // may sit between the pair, and none may rewrite the dividend or divisor. + if (!IsFusionTransparent(inst) + || TouchesVal(inst, p.Src1, read: false) + || TouchesVal(inst, p.Src2, read: false)) + break; + } + } + } + + // Instructions allowed between a fused divmod pair: pure data ops with no control flow + // and no opaque memory/register clobber. A CALL, jump, label, return, indirect store, + // or anything unrecognised ends the search (conservative). + private static bool IsFusionTransparent(Instruction inst) => inst switch + { + // DebugLine is a source-line marker with no register/memory/control effect; it sits + // between two statements (e.g. `hi = x // k` and `lo = x % k`) and must be skipped so + // the divmod pair is still recognized — otherwise the fusion only fired for same- + // statement pairs like the divmod() builtin. + DebugLine => true, + Binary or Unary or Copy or Bitcast or ArrayStore or ArrayLoad or ArrayLoadFlash + or BytearrayLoad or FlashLoadPtr or BitCheck => true, + _ => false, + }; + + // Conservative read/write test for the scalar Val v. Writes: the instruction's + // destination/target equals v. Reads: v appears as a source operand. ArrayStore + // writes array memory (never a scalar Val), so it only reads. Unhandled cases fall + // through to false here because IsFusionTransparent already gates the instruction set. + private static bool TouchesVal(Instruction inst, Val v, bool read) + { + if (read) + return inst switch + { + Binary x => Equals(x.Src1, v) || Equals(x.Src2, v), + Unary x => Equals(x.Src, v), + Copy x => Equals(x.Src, v), + Bitcast x => Equals(x.Src, v), + ArrayStore x => Equals(x.Index, v) || Equals(x.Src, v), + ArrayLoad x => Equals(x.Index, v), + ArrayLoadFlash x => Equals(x.Index, v), + BytearrayLoad x => Equals(x.Index, v), + FlashLoadPtr x => Equals(x.Ptr, v) || Equals(x.Index, v), + BitCheck x => Equals(x.Source, v), + _ => false, + }; + return inst switch + { + Binary x => Equals(x.Dst, v), + Unary x => Equals(x.Dst, v), + Copy x => Equals(x.Dst, v), + Bitcast x => Equals(x.Dst, v), + ArrayLoad x => Equals(x.Dst, v), + ArrayLoadFlash x => Equals(x.Dst, v), + BytearrayLoad x => Equals(x.Dst, v), + FlashLoadPtr x => Equals(x.Dst, v), + BitCheck x => Equals(x.Dst, v), + _ => false, + }; + } + + // Emits one division for a fused divide/modulo pair and stores both results. + // 16-bit (__div16): quotient in R24:R25, remainder in R26:R27 — stash the remainder in + // R21:R20 (the divisor R18:R19 is dead) so storing the quotient (which may use X=R26:R27) + // cannot clobber it. 8-bit (__div8): quotient in R24, remainder in R25 — stash the + // remainder in R19 (divisor hi byte, unused for 8-bit) before storing the quotient. + private void EmitFusedDivMod(Binary primary, Val quotDst, Val remDst) + { + var opType = DivModOpType(primary); // unsigned, gated to 8- or 16-bit in DetectDivModFusions + LoadIntoReg(primary.Src1, "R24", opType); + LoadIntoReg(primary.Src2, "R18", opType); + if (opType.SizeOf() == 2) + { + Emit("CALL", "__div16"); // R24:R25 = quotient, R26:R27 = remainder + Emit("MOVW", "R20", "R26"); // stash remainder in R21:R20 + StoreRegInto("R24", quotDst, opType); + Emit("MOVW", "R24", "R20"); // remainder -> R24:R25 + StoreRegInto("R24", remDst, opType); + } + else + { + Emit("CALL", "__div8"); // R24 = quotient, R25 = remainder + Emit("MOV", "R19", "R25"); // stash remainder (R19 = divisor hi, dead at 8-bit) + StoreRegInto("R24", quotDst, opType); + Emit("MOV", "R24", "R19"); // remainder -> R24 + StoreRegInto("R24", remDst, opType); + } + } + + // Detects the byte-pack idiom -- result = (hi << 8) lo, op in {Add, BitOr} -- in a + // single basic block and records the fusion. The shift's destination must be a 16-bit + // value used only by the combine; the other combine operand (lo) must be a byte (so its + // value lands entirely in the low byte with no carry/overlap). Both operand orders of a + // commutative combine are handled. Between the two ops nothing may rewrite hi or lo, the + // shift's result must stay untouched, and no control flow may intervene. + private void DetectBytePackFusions(Function func) + { + _bytePackFuse.Clear(); + _bytePackSkip.Clear(); + var body = func.Body; + + // tmp is safe to drop only if the combine is its sole reader. + int UsesOf(Val v) + { + int c = 0; + foreach (var ins in body) + if (TouchesVal(ins, v, read: true)) c++; + return c; + } + + for (int i = 0; i < body.Count; i++) + { + if (body[i] is not Binary sh || sh.Op != IrBinOp.LShift) continue; + if (sh.Src2 is not Constant { Value: 8 }) continue; + if (GetValType(sh.Dst).SizeOf() != 2) continue; // shift result is 16-bit + Val tmp = sh.Dst; + + for (int j = i + 1; j < body.Count; j++) + { + var inst = body[j]; + + if (inst is Binary cmb && cmb.Op is IrBinOp.Add or IrBinOp.BitOr + && GetValType(cmb.Dst).SizeOf() == 2) + { + Val? lo = Equals(cmb.Src1, tmp) ? cmb.Src2 + : Equals(cmb.Src2, tmp) ? cmb.Src1 : null; + if (lo != null && GetValType(lo).SizeOf() == 1 && UsesOf(tmp) == 1) + { + bool clean = true; + for (int k = i + 1; k < j && clean; k++) + if (TouchesVal(body[k], tmp, read: true) + || TouchesVal(body[k], sh.Src1, read: false) + || TouchesVal(body[k], lo, read: false)) + clean = false; + if (clean) + { + _bytePackFuse[cmb] = (sh.Src1, lo); + _bytePackSkip.Add(sh); + } + } + break; // tmp's combine resolved + } + + if (!IsFusionTransparent(inst) + || TouchesVal(inst, tmp, read: true) + || TouchesVal(inst, sh.Src1, read: false)) + break; + } + } + } + + // Emits result = (hi << 8) | lo as a direct byte placement: low byte = lo, high byte = + // the low byte of hi (matching a 16-bit shift-by-8). hi -> R25 first so loading lo into + // R24 cannot clobber it (neither byte source is ever homed in the R24/R25 scratch pair). + private void EmitBytePack(Binary combine, Val hi, Val lo) + { + LoadIntoReg(hi, "R25", DataType.UINT8); + LoadIntoReg(lo, "R24", DataType.UINT8); + StoreRegInto("R24", combine.Dst, GetValType(combine.Dst)); + } + private void CompileBinary(Binary b) { + if (_divModSkip.Contains(b)) return; // folded into its paired __div16 + if (_divModFuse.TryGetValue(b, out var pair)) + { + EmitFusedDivMod(b, pair.Quot, pair.Rem); + return; + } + if (_bytePackSkip.Contains(b)) return; // dead `hi << 8`, folded into the pack + if (_bytePackFuse.TryGetValue(b, out var bp)) + { + EmitBytePack(b, bp.Hi, bp.Lo); + return; + } + DataType type = GetValType(b.Dst); // Delegate float operations to soft-float routines. if (type == DataType.FLOAT @@ -1732,6 +2417,12 @@ private void CompileBinary(Binary b) CompileFloatBinary(b); return; } + + // Destination-targeted in-place fast path (8-bit reg-reg / immediate arithmetic): + // compute the result directly in dst's home register, skipping the R24 stage and + // the store-back (and the src1 load when dst == src1). Falls back to the staged + // path below when AVR register classes or the operand shape don't permit it. + if (TryCompileBinaryInPlace(b)) return; // For Div/Mod and shift ops the source may be wider than the destination // (e.g. uint16 >> 8 → uint8 must operate as 16-bit to read the high byte). var src1Type = GetValType(b.Src1); @@ -1772,14 +2463,19 @@ or IrBinOp.RShift or IrBinOp.LShift or IrBinOp.BitAnd int byteShift = val / 8; int bitShift = val % 8; bool s32 = IsSignedType(type); + // Sign-fill byte for the bytes vacated by a whole-byte shift (0xFF if the + // value is negative, else 0x00). Computed from the MSB R23 before the moves + // overwrite it; R26 is scratch in the constant-operand path. Without this a + // signed >> by 8..31 shifts in zeros (logical) instead of the sign. + if (s32 && byteShift >= 1) { Emit("CLR","R26"); Emit("SBRC","R23","7"); Emit("COM","R26"); } if (byteShift >= 4) { - if (s32) { Emit("MOV","R24","R23"); Emit("LSL","R24"); Emit("SBC","R24","R24"); Emit("MOV","R25","R24"); Emit("MOV","R22","R24"); Emit("MOV","R23","R24"); } + if (s32) { Emit("MOV","R24","R26"); Emit("MOV","R25","R26"); Emit("MOV","R22","R26"); Emit("MOV","R23","R26"); } else { Emit("CLR","R24"); Emit("CLR","R25"); Emit("CLR","R22"); Emit("CLR","R23"); } } - else if (byteShift == 3) { Emit("MOV","R24","R23"); Emit("CLR","R25"); Emit("CLR","R22"); Emit("CLR","R23"); } - else if (byteShift == 2) { Emit("MOV","R24","R22"); Emit("MOV","R25","R23"); Emit("CLR","R22"); Emit("CLR","R23"); } - else if (byteShift == 1) { Emit("MOV","R24","R25"); Emit("MOV","R25","R22"); Emit("MOV","R22","R23"); Emit("CLR","R23"); } + else if (byteShift == 3) { Emit("MOV","R24","R23"); if (s32) { Emit("MOV","R25","R26"); Emit("MOV","R22","R26"); Emit("MOV","R23","R26"); } else { Emit("CLR","R25"); Emit("CLR","R22"); Emit("CLR","R23"); } } + else if (byteShift == 2) { Emit("MOV","R24","R22"); Emit("MOV","R25","R23"); if (s32) { Emit("MOV","R22","R26"); Emit("MOV","R23","R26"); } else { Emit("CLR","R22"); Emit("CLR","R23"); } } + else if (byteShift == 1) { Emit("MOV","R24","R25"); Emit("MOV","R25","R22"); Emit("MOV","R22","R23"); if (s32) Emit("MOV","R23","R26"); else Emit("CLR","R23"); } for (int i = 0; i < bitShift; i++) { if (s32) Emit("ASR","R23"); else Emit("LSR","R23"); @@ -1883,10 +2579,19 @@ or IrBinOp.RShift or IrBinOp.LShift or IrBinOp.BitAnd bool s16 = IsSignedType(opType); if (byteShift >= 2) { - if (s16) { Emit("MOV","R24","R25"); Emit("LSL","R24"); Emit("SBC","R24","R24"); Emit("CLR","R25"); } + // Shift >= 16: result is all sign bits. SBC R24,R24 leaves 0xFF/0x00 + // from carry; both bytes must take it (a signed -1 is 0xFFFF, not 0x00FF). + if (s16) { Emit("MOV","R24","R25"); Emit("LSL","R24"); Emit("SBC","R24","R24"); Emit("MOV","R25","R24"); } else { Emit("CLR","R24"); Emit("CLR","R25"); } } - else if (byteShift == 1) { Emit("MOV","R24","R25"); Emit("CLR","R25"); } + else if (byteShift == 1) + { + Emit("MOV","R24","R25"); // low byte := old high byte + // Vacated high byte must be the sign extension (0xFF if negative), + // not zero — otherwise a signed >> by 8..15 shifts in zeros (logical). + if (s16) { Emit("CLR","R25"); Emit("SBRC","R24","7"); Emit("COM","R25"); } + else Emit("CLR","R25"); + } for (int i = 0; i < bitShift; i++) { if (s16) Emit("ASR","R25"); else Emit("LSR","R25"); @@ -1909,15 +2614,31 @@ or IrBinOp.RShift or IrBinOp.LShift or IrBinOp.BitAnd } } - if (!usedImm) LoadIntoReg(b.Src2, "R18", opType); + // Second operand. When it already lives in a home register of the matching + // width, use that register pair directly as the ALU operand instead of staging + // it through R18 — this drops one MOV per reg-reg arithmetic op (the codegen's + // "stage-through-R18" pattern is otherwise pure overhead for register-resident + // operands). Only for non-widening reg-reg ops; variable shifts clobber R18 and + // 32-bit values have no register homes, so both keep staging. + string s2lo = "R18", s2hi = "R19"; + if (!usedImm) + { + bool coalesceable = !is32 + && b.Op is IrBinOp.Add or IrBinOp.Sub or IrBinOp.BitAnd + or IrBinOp.BitOr or IrBinOp.BitXor + && GetValType(b.Src2).SizeOf() == opType.SizeOf(); + string? home = coalesceable ? OperandHomeReg(b.Src2) : null; + if (home != null) { s2lo = home; s2hi = GetHighReg(home); } + else LoadIntoReg(b.Src2, "R18", opType); + } switch (b.Op) { case IrBinOp.Add: if (!usedImm) { - Emit("ADD", "R24", "R18"); - if (is16 || is32) Emit("ADC", "R25", "R19"); + Emit("ADD", "R24", s2lo); + if (is16 || is32) Emit("ADC", "R25", s2hi); if (is32) { Emit("ADC", "R22", "R20"); Emit("ADC", "R23", "R21"); } } @@ -1925,8 +2646,8 @@ or IrBinOp.RShift or IrBinOp.LShift or IrBinOp.BitAnd case IrBinOp.Sub: if (!usedImm) { - Emit("SUB", "R24", "R18"); - if (is16 || is32) Emit("SBC", "R25", "R19"); + Emit("SUB", "R24", s2lo); + if (is16 || is32) Emit("SBC", "R25", s2hi); if (is32) { Emit("SBC", "R22", "R20"); Emit("SBC", "R23", "R21"); } } @@ -1934,8 +2655,8 @@ or IrBinOp.RShift or IrBinOp.LShift or IrBinOp.BitAnd case IrBinOp.BitAnd: if (!usedImm) { - Emit("AND", "R24", "R18"); - if (is16 || is32) Emit("AND", "R25", "R19"); + Emit("AND", "R24", s2lo); + if (is16 || is32) Emit("AND", "R25", s2hi); if (is32) { Emit("AND", "R22", "R20"); Emit("AND", "R23", "R21"); } } @@ -1943,15 +2664,15 @@ or IrBinOp.RShift or IrBinOp.LShift or IrBinOp.BitAnd case IrBinOp.BitOr: if (!usedImm) { - Emit("OR", "R24", "R18"); - if (is16 || is32) Emit("OR", "R25", "R19"); + Emit("OR", "R24", s2lo); + if (is16 || is32) Emit("OR", "R25", s2hi); if (is32) { Emit("OR", "R22", "R20"); Emit("OR", "R23", "R21"); } } break; case IrBinOp.BitXor: - Emit("EOR", "R24", "R18"); - if (is16 || is32) Emit("EOR", "R25", "R19"); + Emit("EOR", "R24", s2lo); + if (is16 || is32) Emit("EOR", "R25", s2hi); if (is32) { Emit("EOR", "R22", "R20"); Emit("EOR", "R23", "R21"); } break; case IrBinOp.LShift: @@ -2003,7 +2724,13 @@ or IrBinOp.RShift or IrBinOp.LShift or IrBinOp.BitAnd break; case IrBinOp.Mul: - if (is16) + if (is32) + { + // 32-bit product (low 32 bits) via the runtime routine; the inline 8-bit + // fallthrough below would otherwise multiply only the low bytes. + Emit("CALL", "__mul32"); + } + else if (is16) { // 16x16 -> 16-bit product (low 16 bits only). // a = R25:R24 (hi:lo), b = R19:R18 (hi:lo). @@ -2047,14 +2774,10 @@ or IrBinOp.RShift or IrBinOp.LShift or IrBinOp.BitAnd break; case IrBinOp.Div: case IrBinOp.FloorDiv: - if (is32) Emit("CALL", "__div32"); - else if (is16) Emit("CALL", "__div16"); - else Emit("CALL", "__div8"); + Emit("CALL", DivModRoutine(false, is32, is16, IsSignedComparison(b.Src1, b.Src2))); break; case IrBinOp.Mod: - if (is32) Emit("CALL", "__mod32"); - else if (is16) Emit("CALL", "__mod16"); - else Emit("CALL", "__mod8"); + Emit("CALL", DivModRoutine(true, is32, is16, IsSignedComparison(b.Src1, b.Src2))); break; case IrBinOp.Equal: { @@ -2454,7 +3177,11 @@ private void CompileAugAssign(AugAssign aa) break; } case IrBinOp.Mul: - if (is16) + if (is32) + { + Emit("CALL", "__mul32"); + } + else if (is16) { // a = R25:R24 (hi:lo), b = R19:R18 (hi:lo). if (IsSignedType(type)) @@ -2495,14 +3222,10 @@ private void CompileAugAssign(AugAssign aa) break; case IrBinOp.Div: case IrBinOp.FloorDiv: - if (is32) Emit("CALL", "__div32"); - else if (is16) Emit("CALL", "__div16"); - else Emit("CALL", "__div8"); + Emit("CALL", DivModRoutine(false, is32, is16, IsSignedComparison(aa.Target, aa.Operand))); break; case IrBinOp.Mod: - if (is32) Emit("CALL", "__mod32"); - else if (is16) Emit("CALL", "__mod16"); - else Emit("CALL", "__mod8"); + Emit("CALL", DivModRoutine(true, is32, is16, IsSignedComparison(aa.Target, aa.Operand))); break; case IrBinOp.Equal: case IrBinOp.NotEqual: @@ -2549,9 +3272,8 @@ private void CompileArrayLoad(ArrayLoad al) var absBase = 0x0100 + baseOffset; Emit("LDI", "R30", $"low({absBase})"); Emit("LDI", "R31", $"high({absBase})"); - Emit("CLR", "R16"); // R16 = 0 (Clears carry, but we don't care yet) Emit("ADD", "R30", "R24"); // Add offset to Z low byte (Generates carry if overflow) - Emit("ADC", "R31", "R16"); // Add 0 + carry to Z high byte + Emit("ADC", "R31", "R1"); // R1 == 0; avoids clobbering an R16 the allocator may hold Emit("LD", "R24", "Z"); if (is16) Emit("LDD", "R25", "Z+1"); } @@ -2595,9 +3317,8 @@ private void CompileArrayStore(ArrayStore ast) var absBase = 0x0100 + baseOffset; Emit("LDI", "R30", $"low({absBase})"); Emit("LDI", "R31", $"high({absBase})"); - Emit("CLR", "R16"); // R16 = 0 Emit("ADD", "R30", "R24"); // Z_low = Z_low + offset (Sets Carry if overflow) - Emit("ADC", "R31", "R16"); // Z_high = Z_high + 0 + Carry + Emit("ADC", "R31", "R1"); // R1 == 0; avoids clobbering an R16 the allocator may hold Emit("ST", "Z", "R18"); if (is16) Emit("STD", "Z+1", "R19"); } @@ -2638,18 +3359,18 @@ private void CompileBytearrayLoad(BytearrayLoad bl) } else if (bl.Index is Constant cIdx3) { - Emit("LDI", "R16", $"{cIdx3.Value}"); - Emit("CLR", "R17"); - Emit("ADD", "R30", "R16"); - Emit("ADC", "R31", "R17"); + // Scratch in R26 (X-low) + R1 (zero reg), never the R16/R17 the linear-scan + // allocator hands out -- so a register-allocated value survives this load. + Emit("LDI", "R26", $"{cIdx3.Value}"); + Emit("ADD", "R30", "R26"); + Emit("ADC", "R31", "R1"); Emit("LD", "R24", "Z"); } else { - LoadIntoReg(bl.Index, "R16"); - Emit("CLR", "R17"); - Emit("ADD", "R30", "R16"); - Emit("ADC", "R31", "R17"); + LoadIntoReg(bl.Index, "R26"); + Emit("ADD", "R30", "R26"); + Emit("ADC", "R31", "R1"); Emit("LD", "R24", "Z"); } @@ -2694,18 +3415,18 @@ private void CompileBytearrayStore(BytearrayStore bs) } else if (bs.Index is Constant cIdx3) { - Emit("LDI", "R16", $"{cIdx3.Value}"); - Emit("CLR", "R17"); - Emit("ADD", "R30", "R16"); - Emit("ADC", "R31", "R17"); + // Scratch in R26 (X-low) + R1 (zero reg), never the R16/R17 the linear-scan + // allocator hands out -- so a register-allocated value survives this store. + Emit("LDI", "R26", $"{cIdx3.Value}"); + Emit("ADD", "R30", "R26"); + Emit("ADC", "R31", "R1"); Emit("ST", "Z", "R18"); } else { - LoadIntoReg(bs.Index, "R16"); - Emit("CLR", "R17"); - Emit("ADD", "R30", "R16"); - Emit("ADC", "R31", "R17"); + LoadIntoReg(bs.Index, "R26"); + Emit("ADD", "R30", "R26"); + Emit("ADC", "R31", "R1"); Emit("ST", "Z", "R18"); } } @@ -2824,6 +3545,19 @@ private void CompileArrayLoadFlash(ArrayLoadFlash alf) StoreRegInto("R24", alf.Dst, DataType.UINT8); } + private void CompileFlashLoadPtr(FlashLoadPtr flp) + { + // Dst = flash[Ptr + Index] via LPM, where Ptr is a runtime 16-bit flash byte-address + // (e.g. a const[str] passed by reference into a non-@inline subroutine). Mirrors + // CompileArrayLoadFlash but with a register-held base instead of a fixed label. + LoadIntoReg(flp.Ptr, "R30", DataType.UINT16); // Z = base flash byte-address + LoadIntoReg(flp.Index, "R24"); // index -> R24 (8-bit) + Emit("ADD", "R30", "R24"); // Z += index + Emit("ADC", "R31", "R1"); // propagate carry (R1 == 0) + Emit("LPM", "R24", "Z"); // load byte from flash + StoreRegInto("R24", flp.Dst, DataType.UINT8); + } + private void EmitFlashArrayPool(TextWriter os) { if (_flashArrayPool.Count == 0) return; @@ -3070,6 +3804,7 @@ private static bool IsVariableReadInBody(string varName, List body) ArrayLoad al => IsV(varName, al.Index), ArrayStore ast => IsV(varName, ast.Index) || IsV(varName, ast.Src), ArrayLoadFlash alf => IsV(varName, alf.Index), + FlashLoadPtr flp => IsV(varName, flp.Ptr) || IsV(varName, flp.Index), BytearrayLoad bl => bl.PtrName == varName || IsV(varName, bl.Index), BytearrayStore bst => bst.PtrName == varName || IsV(varName, bst.Index) || IsV(varName, bst.Src), LoadIndirect li => IsV(varName, li.SrcPtr), @@ -3079,7 +3814,6 @@ private static bool IsVariableReadInBody(string varName, List body) VirtualCall vc => IsV(varName, vc.Self) || vc.Args.Any(a => IsV(varName, a)), InlineAsm ia => ia.Operands?.Any(a => IsV(varName, a)) ?? false, SignalError se => IsV(varName, se.Code), - RaiseExn re => IsV(varName, re.Code), GcAlloc ga => IsV(varName, ga.Size), _ => false, }; @@ -3455,69 +4189,6 @@ private void WriteVarMapIfRequested(ProgramIR program) JsonSerializer.Serialize(entries, AvrVarMapJsonContext.Default.ListVarMapEntry)); } - private void CompileTryBegin(TryBegin tb) - { - string jmpBufName = (tb.JmpBufVar as Variable)?.Name ?? ""; - if (!_stackLayout.TryGetValue(jmpBufName, out int jmpBufOffset)) - throw new Exception($"jmpbuf variable '{jmpBufName}' not found in stack layout"); - - int jmpBufAddr = 0x0100 + jmpBufOffset; - Emit("LDI", "R24", $"lo8({jmpBufAddr})"); - Emit("LDI", "R25", $"hi8({jmpBufAddr})"); - - if (_stackLayout.TryGetValue("__pymcu_active_jmpbuf", out int activeOffset)) - { - int activeAddr = 0x0100 + activeOffset; - Emit("STS", activeAddr.ToString(), "R24"); - Emit("STS", (activeAddr + 1).ToString(), "R25"); - } - - Emit("CALL", "setjmp"); - - string exnCodeName = (tb.ExnCodeVar as Variable)?.Name ?? ""; - if (_stackLayout.TryGetValue(exnCodeName, out int exnOffset)) - { - if (exnOffset < 64) - Emit("STD", $"Y+{exnOffset}", "R24"); - else - { - int exnAddr = 0x0100 + exnOffset; - Emit("STS", exnAddr.ToString(), "R24"); - } - } - - Emit("TST", "R24"); - EmitBranch("BRNE", tb.CatchLabel); - } - - private void CompileRaiseExn(RaiseExn re) - { - if (re.Code is Constant c) _usedExnCodes.Add(c.Value); - LoadIntoReg(re.Code, "R22", DataType.UINT8); - Emit("CLR", "R23"); - - if (_stackLayout.TryGetValue("__pymcu_active_jmpbuf", out int activeOffset)) - { - int activeAddr = 0x0100 + activeOffset; - Emit("LDS", "R24", activeAddr.ToString()); - Emit("LDS", "R25", (activeAddr + 1).ToString()); - } - else - { - Emit("LDI", "R24", "0"); - Emit("LDI", "R25", "0"); - } - - string noHandlerLabel = $"L_no_handler_{_labelCounter++}"; - Emit("MOV", "R16", "R24"); - Emit("OR", "R16", "R25"); - Emit("TST", "R16"); - Emit("BREQ", noHandlerLabel); - Emit("CALL", "longjmp"); - EmitLabel(noHandlerLabel); - Emit("CALL", "__pymcu_unhandled_exn"); - } - // ------------------------------------------------------------------------- // T-flag error propagation (ABI interna PyMCU — reemplaza SJLJ) // ------------------------------------------------------------------------- @@ -3532,6 +4203,18 @@ private void CompileSignalError(SignalError se) if (se.Code is not Constant { Value: 0 }) LoadIntoReg(se.Code, "R22", DataType.UINT8); + if (se.CatchLabel != null) + { + // Local raise: the `raise` is lexically inside a `try` body in this same + // function. Deliver the error code straight to the local catch dispatcher. + // The dispatcher discriminates on R22 alone, so we neither set T (no caller + // is involved) nor RET — we just jump. Leaving T untouched means a locally + // caught raise can never leak an error to a later call site or the caller. + // JMP (vs RJMP) is range-safe; the linker relaxes it to RJMP under -mrelax. + Emit("JMP", se.CatchLabel); + return; + } + Emit("SET"); // BSET 6 — T = 1 (signal error to caller) // SignalError is terminal: return immediately with T = 1. diff --git a/src/csharp/lib/Targets/AVR/AvrGpiorPromotion.cs b/src/csharp/lib/Targets/AVR/AvrGpiorPromotion.cs new file mode 100644 index 00000000..fe844da2 --- /dev/null +++ b/src/csharp/lib/Targets/AVR/AvrGpiorPromotion.cs @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: MIT +// PyMCU AVR Backend — GPIOR promotion for ISR-shared globals. + +using PyMCU.Common.Models; +using PyMCU.IR; + +namespace PyMCU.Backend.Targets.AVR; + +/// +/// Promotes single-byte ISR-shared globals (ProgramIR.IsrSharedGlobals, computed +/// by the core optimizer) from SRAM to the chip's general-purpose I/O registers +/// GPIOR0..GPIOR2. A promoted global is rewritten in place: every Variable +/// reference becomes a MemoryAddress at the GPIOR's data-space address, so the +/// existing codegen emits IN/OUT (1 cycle, always volatile) instead of LDS/STS, +/// and SBI/CBI/SBIS/SBIC for bit operations on GPIOR0 — exactly the access +/// pattern an expert writes by hand for ISR↔main flags. +/// +/// Safety rules: +/// - Only UINT8 globals are eligible (MemoryAddress of size 1 is typed UINT8 +/// by the codegen, so promoting an INT8 would lose signedness in compares). +/// - A global is skipped if it appears in an InlineAsm operand list, inside a +/// naked asm string by mangled name, or in a VirtualCall / GcRoot / GcUnroot +/// (those paths resolve names by SRAM address or register convention). +/// - A GPIOR already referenced explicitly by the program (e.g. user code that +/// imports GPIOR0 from pymcu.chips.*) is never reassigned. +/// - Chips whose GPIOR layout is not in the table get no promotion at all — +/// the globals simply stay in SRAM, which is always correct. +/// +public static class AvrGpiorPromotion +{ + /// + /// Data-space addresses of GPIOR0..GPIOR2, ordered so the bit-addressable + /// register (SBI/CBI reachable, data address ≤ 0x3F) comes first. + /// + private static int[]? GpiorAddressesFor(string chip) + { + if (string.IsNullOrEmpty(chip)) return null; + var c = chip.ToLowerInvariant(); + + // Classic megaAVR layout: GPIOR0 = 0x3E (I/O 0x1E, bit-addressable), + // GPIOR1 = 0x4A, GPIOR2 = 0x4B. + string[] mega = + [ + "atmega328", "atmega168", "atmega88", "atmega48", + "atmega2560", "atmega1280", "atmega640", + "atmega32u4", "atmega16u4", + "atmega1284", "atmega644", "atmega324", "atmega164", + ]; + if (mega.Any(c.StartsWith)) + return [0x3E, 0x4A, 0x4B]; + + // tinyAVR (25/45/85 family): GPIOR0 = 0x31, GPIOR1 = 0x32, GPIOR2 = 0x33, + // all three bit-addressable (I/O 0x11..0x13). + string[] tiny = ["attiny25", "attiny45", "attiny85", "attiny24", "attiny44", "attiny84"]; + if (tiny.Any(c.StartsWith)) + return [0x31, 0x32, 0x33]; + + return null; + } + + /// + /// Mutates : rewrites references to the promoted + /// globals and removes them from Globals (they no longer occupy BSS). + /// Returns the promotion map (global name → data-space address) for the + /// assembly header comment; empty when nothing was promoted. + /// + public static Dictionary Apply(ProgramIR program, DeviceConfig cfg) + { + var result = new Dictionary(); + if (program.IsrSharedGlobals.Count == 0) return result; + + // The backend CLI populates TargetChip (source of truth); Chip is only + // set on the in-process path. Accept either. + var chip = !string.IsNullOrEmpty(cfg.Chip) ? cfg.Chip : cfg.TargetChip; + var gpiors = GpiorAddressesFor(chip); + if (gpiors == null) return result; + + var eligible = program.Globals + .Where(g => g.Type == DataType.UINT8 && program.IsrSharedGlobals.Contains(g.Name)) + .Select(g => g.Name) + .ToHashSet(); + if (eligible.Count == 0) return result; + + // Exclusions + explicit-GPIOR conflict scan + use counting, single walk. + var usedAddresses = new HashSet(); + var useCount = eligible.ToDictionary(n => n, _ => 0); + foreach (var instr in program.Functions.SelectMany(f => f.Body)) + { + switch (instr) + { + case InlineAsm { Operands: not null } ia: + foreach (var op in ia.Operands) + if (op is Variable v) eligible.Remove(v.Name); + break; + case InlineAsm { Code: var asmStr }: + foreach (var n in eligible.Where(n => asmStr.Contains(n.Replace('.', '_'))).ToList()) + eligible.Remove(n); + break; + case VirtualCall vc: + eligible.Remove(vc.Self.Name); + foreach (var a in vc.Args) + if (a is Variable av) eligible.Remove(av.Name); + break; + case GcRoot { Var: Variable grv }: eligible.Remove(grv.Name); break; + case GcUnroot { Var: Variable guv }: eligible.Remove(guv.Name); break; + } + + VisitVals(instr, val => + { + if (val is MemoryAddress mem) usedAddresses.Add(mem.Address); + if (val is Variable v && useCount.ContainsKey(v.Name)) useCount[v.Name]++; + }); + } + + var freeGpiors = gpiors.Where(a => !usedAddresses.Contains(a)).ToList(); + if (freeGpiors.Count == 0) return result; + + // Most-used global first: it gets GPIOR0, the SBI/CBI-reachable register. + var winners = useCount + .Where(kv => eligible.Contains(kv.Key) && kv.Value > 0) + .OrderByDescending(kv => kv.Value) + .ThenBy(kv => kv.Key, StringComparer.Ordinal) + .Take(freeGpiors.Count) + .Select((kv, i) => (Name: kv.Key, Address: freeGpiors[i])) + .ToList(); + if (winners.Count == 0) return result; + + foreach (var (name, address) in winners) + result[name] = address; + + Val Sub(Val val) => + val is Variable v && result.TryGetValue(v.Name, out var addr) + ? new MemoryAddress(addr, v.Type) + : val; + + foreach (var func in program.Functions) + for (int i = 0; i < func.Body.Count; i++) + func.Body[i] = RewriteVals(func.Body[i], Sub); + + program.Globals.RemoveAll(g => result.ContainsKey(g.Name)); + return result; + } + + private static void VisitVals(Instruction instr, Action visit) + { + // Visiting Dst positions too: a write is a reference for both the + // use counter and the conflict scan. + RewriteVals(instr, v => { visit(v); return v; }); + } + + /// Rewrites every Val operand (sources and destinations) of an instruction. + private static Instruction RewriteVals(Instruction instr, Func f) => instr switch + { + Return r => r with { Value = f(r.Value) }, + Unary u => u with { Src = f(u.Src), Dst = f(u.Dst) }, + Binary b => b with { Src1 = f(b.Src1), Src2 = f(b.Src2), Dst = f(b.Dst) }, + Copy c => c with { Src = f(c.Src), Dst = f(c.Dst) }, + Bitcast bc => bc with { Src = f(bc.Src), Dst = f(bc.Dst) }, + LoadIndirect li => li with { SrcPtr = f(li.SrcPtr), Dst = f(li.Dst) }, + StoreIndirect si => si with { Src = f(si.Src), DstPtr = f(si.DstPtr) }, + JumpIfZero j => j with { Condition = f(j.Condition) }, + JumpIfNotZero j => j with { Condition = f(j.Condition) }, + JumpIfEqual j => j with { Src1 = f(j.Src1), Src2 = f(j.Src2) }, + JumpIfNotEqual j => j with { Src1 = f(j.Src1), Src2 = f(j.Src2) }, + JumpIfLessThan j => j with { Src1 = f(j.Src1), Src2 = f(j.Src2) }, + JumpIfLessOrEqual j => j with { Src1 = f(j.Src1), Src2 = f(j.Src2) }, + JumpIfGreaterThan j => j with { Src1 = f(j.Src1), Src2 = f(j.Src2) }, + JumpIfGreaterOrEqual j => j with { Src1 = f(j.Src1), Src2 = f(j.Src2) }, + Call cl => cl with { Args = cl.Args.Select(f).ToList(), Dst = f(cl.Dst) }, + IndirectCall ic => ic with { FuncAddr = f(ic.FuncAddr), Args = ic.Args.Select(f).ToList(), Dst = f(ic.Dst) }, + BitSet bs => bs with { Target = f(bs.Target) }, + BitClear bc => bc with { Target = f(bc.Target) }, + BitCheck bck => bck with { Source = f(bck.Source), Dst = f(bck.Dst) }, + BitWrite bw => bw with { Target = f(bw.Target), Src = f(bw.Src) }, + JumpIfBitSet j => j with { Source = f(j.Source) }, + JumpIfBitClear j => j with { Source = f(j.Source) }, + AugAssign aa => aa with { Target = f(aa.Target), Operand = f(aa.Operand) }, + ArrayLoad al => al with { Index = f(al.Index), Dst = f(al.Dst) }, + ArrayStore ast => ast with { Index = f(ast.Index), Src = f(ast.Src) }, + ArrayLoadFlash alf => alf with { Index = f(alf.Index), Dst = f(alf.Dst) }, + FlashLoadPtr flp => flp with { Ptr = f(flp.Ptr), Index = f(flp.Index), Dst = f(flp.Dst) }, + BytearrayLoad bld => bld with { Index = f(bld.Index), Dst = f(bld.Dst) }, + BytearrayStore bst => bst with { Index = f(bst.Index), Src = f(bst.Src) }, + GcAlloc ga => ga with { Size = f(ga.Size), Dst = f(ga.Dst) }, + SignalError se => se with { Code = f(se.Code) }, + // InlineAsm / VirtualCall / GcRoot / GcUnroot: operands resolve by name, + // register convention, or SRAM address — globals used there are excluded + // from promotion in Apply(), so the instructions pass through unchanged. + _ => instr, + }; +} diff --git a/src/csharp/lib/Targets/AVR/AvrLinearScan.cs b/src/csharp/lib/Targets/AVR/AvrLinearScan.cs index 48a69337..3321933f 100644 --- a/src/csharp/lib/Targets/AVR/AvrLinearScan.cs +++ b/src/csharp/lib/Targets/AVR/AvrLinearScan.cs @@ -46,7 +46,10 @@ void VisitVal(Val val, int i) for (int i = 0; i < func.Body.Count; ++i) { var instr = func.Body[i]; - if (instr is Call) callIndices.Add(i); + // IndirectCall and GcAlloc transfer control to a callee/allocator and clobber the + // caller-saved scratch the same way a direct Call does, so a temp whose live range + // spans them must be spilled rather than kept in R16/R17. + if (instr is Call or IndirectCall or GcAlloc) callIndices.Add(i); switch (instr) { @@ -114,6 +117,11 @@ void VisitVal(Val val, int i) VisitVal(cl.Dst, i); foreach (var a in cl.Args) VisitVal(a, i); break; + case FlashLoadPtr flp: + VisitVal(flp.Ptr, i); + VisitVal(flp.Index, i); + VisitVal(flp.Dst, i); + break; case LoadIndirect li: VisitVal(li.SrcPtr, i); VisitVal(li.Dst, i); @@ -122,6 +130,46 @@ void VisitVal(Val val, int i) VisitVal(si.DstPtr, i); VisitVal(si.Src, i); break; + // Array/bytearray ops were absent here, so a temp DEFINED by an ArrayLoad (or used + // as an index/source) had no interval at that point -- its live range was seen as + // starting only at a later consumer. Two temps that truly overlap (an earlier load + // result still live while a second load's index is computed) then shared R16 and + // clobbered each other (`arr[idx] + arr[s - 5]` returned just the second element). + case ArrayLoad al2: + VisitVal(al2.Index, i); + VisitVal(al2.Dst, i); + break; + case ArrayLoadFlash alf2: + VisitVal(alf2.Index, i); + VisitVal(alf2.Dst, i); + break; + case ArrayStore ast2: + VisitVal(ast2.Index, i); + VisitVal(ast2.Src, i); + break; + case BytearrayLoad bld2: + VisitVal(bld2.Index, i); + VisitVal(bld2.Dst, i); + break; + case BytearrayStore bst2: + VisitVal(bst2.Index, i); + VisitVal(bst2.Src, i); + break; + // Same omission as the array ops: an indirect call's result and a GC allocation's + // pointer are temps that must be tracked, or they share a slot with an overlapping + // temp and get clobbered (`fps[0](s) + fps[1](s)` lost the first call's result). + case IndirectCall ic: + VisitVal(ic.FuncAddr, i); + foreach (var a in ic.Args) VisitVal(a, i); + VisitVal(ic.Dst, i); + break; + case GcAlloc ga: + VisitVal(ga.Size, i); + VisitVal(ga.Dst, i); + break; + case SignalError se: + VisitVal(se.Code, i); + break; } } @@ -138,33 +186,47 @@ void VisitVal(Val val, int i) } } - // Collect eligible (UINT8, no call span), sort by def + // Collect eligible (1- or 2-byte scalar temps that do not span a call), by def. + // 16-bit temps were previously always spilled to stack slots; allowing them to + // occupy the R16:R17 pair removes the store/reload traffic the codegen otherwise + // emits around every uint16 temporary (StoreRegInto/LoadIntoReg already drive the + // high byte via GetHighReg, so a pair-homed temp needs no codegen change). var eligible = intervals.Values - .Where(iv => !iv.SpansCall && iv.Type == DataType.UINT8) + .Where(iv => !iv.SpansCall && (iv.Type.SizeOf() == 1 || iv.Type.SizeOf() == 2)) .OrderBy(iv => iv.Def) .ToList(); - // Greedy assignment to R16/R17 + // Two byte-slots: slot[0] = R16, slot[1] = R17. An 8-bit temp takes one slot; a + // 16-bit temp takes the pair (R16:R17, low in R16). Greedy with last-use expiry. var result = new Dictionary(); - var active = new LiveInterval?[2]; + var slot = new LiveInterval?[2]; foreach (var iv in eligible) { for (int k = 0; k < 2; ++k) - { - if (active[k] != null && active[k]!.LastUse < iv.Def) - active[k] = null; - } + if (slot[k] != null && slot[k]!.LastUse < iv.Def) + slot[k] = null; - for (int k = 0; k < 2; ++k) + if (iv.Type.SizeOf() == 2) { - if (active[k] == null) + // Needs the whole pair free. + if (slot[0] == null && slot[1] == null) { - result[iv.Name] = k == 0 ? "R16" : "R17"; - active[k] = iv; - break; + result[iv.Name] = "R16"; + slot[0] = iv; + slot[1] = iv; } } + else + { + for (int k = 0; k < 2; ++k) + if (slot[k] == null) + { + result[iv.Name] = k == 0 ? "R16" : "R17"; + slot[k] = iv; + break; + } + } } return result; diff --git a/src/csharp/lib/Targets/AVR/AvrPeephole.cs b/src/csharp/lib/Targets/AVR/AvrPeephole.cs index cadd7321..1955e9d2 100644 --- a/src/csharp/lib/Targets/AVR/AvrPeephole.cs +++ b/src/csharp/lib/Targets/AVR/AvrPeephole.cs @@ -78,6 +78,106 @@ public override string ToString() public static class AvrPeephole { + // Mnemonics that write exactly their first operand register and nothing else + // relevant to slot tracking. (STD/LDD are handled separately; LD/ST and the + // pointer/multi-register writers are deliberately excluded so they hit the + // conservative clear-all path.) + private static readonly HashSet SingleDstWriters = new() + { + "MOV", "LDI", "LDS", "IN", "POP", "CLR", "SER", "COM", "NEG", "INC", "DEC", + "LSR", "LSL", "ASR", "ROR", "ROL", "SWAP", "AND", "ANDI", "OR", "ORI", "EOR", + "ADD", "ADC", "SUB", "SUBI", "SBC", "SBCI", "BLD", "SBR", "CBR", + }; + + // Mnemonics that touch no general-purpose register (status flags, I/O bits, + // memory stores, control). These leave slot tracking intact. + private static readonly HashSet NonRegWriters = new() + { + "CP", "CPC", "CPI", "TST", "OUT", "PUSH", "SBI", "CBI", "SBIS", "SBIC", + "SBRS", "SBRC", "BST", "NOP", "SEC", "CLC", "SEI", "CLI", "SET", "CLT", + "SEZ", "CLZ", "WDR", "SLEEP", + }; + + /// + /// Basic-block-local store-to-load forwarding. slotReg[off] records the + /// general-purpose register that currently holds the same value as stack slot + /// Y+off. A reload that re-reads a slot the register already mirrors, + /// and a store of a value already present in the slot, are removed. Tracking + /// is invalidated whenever the mirrored register is overwritten and cleared + /// entirely at any block boundary or unmodeled instruction. + /// + private static void ForwardStores(List lines, ref bool changed) + { + var slotReg = new Dictionary(); + + void KillReg(int r) + { + if (r < 0) return; + List? dead = null; + foreach (var kv in slotReg) + if (kv.Value == r) (dead ??= new()).Add(kv.Key); + if (dead != null) + foreach (var k in dead) slotReg.Remove(k); + } + + for (int i = 0; i < lines.Count; i++) + { + var ln = lines[i]; + if (ln.Type is AvrAsmLine.LineType.Comment + or AvrAsmLine.LineType.Empty + or AvrAsmLine.LineType.DebugMarker) + continue; + if (ln.Type != AvrAsmLine.LineType.Instruction) + { + slotReg.Clear(); // label / raw / anything non-instruction + continue; + } + + switch (ln.Mnemonic) + { + case "STD": + { + int off = ParseYOffset(ln.Op1); + int rx = ParseReg(ln.Op2); + if (off < 0 || rx < 0) { slotReg.Clear(); break; } + if (slotReg.TryGetValue(off, out int cur) && cur == rx) + { + // Slot already holds this register's (unchanged) value. + lines[i] = AvrAsmLine.MakeEmpty(); + changed = true; + break; + } + slotReg[off] = rx; + break; + } + case "LDD": + { + int off = ParseYOffset(ln.Op2); + int ry = ParseReg(ln.Op1); + if (off < 0 || ry < 0) { slotReg.Clear(); break; } + if (slotReg.TryGetValue(off, out int rx) && rx == ry) + { + // Register already mirrors this slot — redundant reload. + lines[i] = AvrAsmLine.MakeEmpty(); + changed = true; + break; + } + KillReg(ry); // ry is overwritten by the load + slotReg[off] = ry; // ry now mirrors Y+off + break; + } + default: + { + if (SingleDstWriters.Contains(ln.Mnemonic)) + KillReg(ParseReg(ln.Op1)); + else if (!NonRegWriters.Contains(ln.Mnemonic)) + slotReg.Clear(); // unknown / multi-write / flow: forget everything + break; + } + } + } + } + private static bool IsFlowTerminator(AvrAsmLine line) { return line.Type == AvrAsmLine.LineType.Instruction && @@ -369,7 +469,818 @@ b2.Mnemonic is not ("INC" or "DEC" or "COM" or "NEG") || result.RemoveAll(l => l.Type == AvrAsmLine.LineType.Empty); } + // --- Redundant variable-index array-address (Z) materialization removal --- + // arr[i] (variable i) lowers to a 6-instruction recipe building base+i*scale + // into Z, then LD/ST through Z (which leaves Z intact). Two accesses to the same + // array with the same index in one straight-line region rebuild the identical + // recipe; with the index source and Z untouched between them, the rebuild is + // dead. Runs before ForwardStores so the canonical recipe is still intact. + var zsChanged = true; + while (zsChanged) + { + zsChanged = false; + EliminateRedundantZSetup(result, ref zsChanged); + if (zsChanged) + result.RemoveAll(l => l.Type == AvrAsmLine.LineType.Empty); + } + + // --- Basic-block store-to-load forwarding & redundant load/store removal --- + // The adjacent STD/LDD patterns above only fire when the store and the + // reload sit next to each other. 16-bit values interleave their lo/hi + // halves (STD lo; STD hi; LDD lo; LDD hi), and call results get parked in + // a slot then immediately reloaded for a compare, so the redundant reload + // is never adjacent to its store. ForwardStores tracks, per basic block, + // which register still mirrors each Y+offset 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: anything it does + // not explicitly model as a pure reader or single-register writer clears + // all tracking, so it never forwards a stale value across an unknown + // effect, a call, a branch, or a label. + var sfChanged = true; + while (sfChanged) + { + sfChanged = false; + ForwardStores(result, ref sfChanged); + if (sfChanged) + result.RemoveAll(l => l.Type == AvrAsmLine.LineType.Empty); + } + + // --- Dead temporary-register move elimination (R16/R17) --- + EliminateDeadTempMoves(result); + + // --- Fuse adjacent immediate ORI/ANDI on the same register --- + // The @inline driver composition emits split constant masks (e.g. + // (nib & 0xF0) | _RS | _BL -> ORI Rd,1; ORI Rd,8). Runs after the dead + // temp-move pass so the staging MOV the codegen parks between the two ops + // is gone, leaving them consecutive. Folding them 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. + var fiChanged = true; + while (fiChanged) + { + fiChanged = false; + FuseImmediates(result, ref fiChanged); + if (fiChanged) + result.RemoveAll(l => l.Type == AvrAsmLine.LineType.Empty); + } + + // --- Conditional branch shortening --- + ShortenBranches(result); + + // --- Redundant register-reload elimination across branches --- + // 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 (the decimal-print's `MOV R9,R24; CPI; BRLO; MOV R24,R9`). + // 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 (conservatively) stop the scan. + var rrChanged = true; + while (rrChanged) + { + rrChanged = false; + EliminateRedundantReloads(result, ref rrChanged); + if (rrChanged) + result.RemoveAll(l => l.Type == AvrAsmLine.LineType.Empty); + } + + // --- Park/unpark round-trip elimination (call-argument shuffle) --- + // `MOV Rh,Rs; ; MOV Rd,Rh` parks a value in a home and + // reloads it because Rs (R24) is reused for another argument. When nothing + // between touches Rh or Rd and Rh is dead afterward, write the value straight + // into Rd instead and drop the reload. + var prChanged = true; + while (prChanged) + { + prChanged = false; + EliminateParkRoundTrip(result, ref prChanged); + if (prChanged) + result.RemoveAll(l => l.Type == AvrAsmLine.LineType.Empty); + } + + // --- Dead pure-write elimination (any register) --- + // Runs last: earlier passes (park/reload removal, immediate fusion) expose writes + // whose result is never read before being overwritten — e.g. the redundant high-byte + // CLR the 16-bit operand widening parks before reusing the register. + var dsChanged = true; + while (dsChanged) + { + dsChanged = false; + EliminateDeadStores(result, ref dsChanged); + if (dsChanged) + result.RemoveAll(l => l.Type == AvrAsmLine.LineType.Empty); + } + result.RemoveAll(l => l.Type == AvrAsmLine.LineType.Empty); return result; } + + // Fuses two consecutive same-register immediate ops (ORI Rd,a; ORI Rd,b -> + // ORI Rd,a|b; likewise ANDI -> a&b). The two need only be consecutive among + // *significant* lines — comments/empties/debug markers between them are fine — + // but a label or raw asm between bails (control could enter, or Rd/flags be + // touched, between them). + private static void FuseImmediates(List lines, ref bool changed) + { + for (int i = 0; i < lines.Count; i++) + { + var a = lines[i]; + if (a.Type != AvrAsmLine.LineType.Instruction) continue; + if (a.Mnemonic is not ("ORI" or "ANDI")) continue; + + int j = NextSignificant(lines, i); + if (j < 0) continue; + var b = lines[j]; + if (b.Type != AvrAsmLine.LineType.Instruction) continue; // label / raw between + if (b.Mnemonic != a.Mnemonic || b.Op1 != a.Op1) continue; // same op, same Rd + if (!TryParseImm8(a.Op2, out int va) || !TryParseImm8(b.Op2, out int vb)) continue; + + // Rd = (Rd op a) op b == Rd op (a op b); b alone fixes Rd and SREG and + // nothing significant reads the intermediate, so flags are preserved. + int fused = a.Mnemonic == "ORI" ? (va | vb) : (va & vb); + lines[i] = AvrAsmLine.MakeEmpty(); + lines[j] = AvrAsmLine.MakeInstruction(a.Mnemonic, a.Op1, fused.ToString()); + changed = true; + } + } + + // True for an assembler directive Raw line (.equ/.org/.byte/...): emits no code, + // touches no register. Hand-written inline asm Raw lines are NOT directives. + private static bool IsDirectiveLine(AvrAsmLine l) + => l.Type == AvrAsmLine.LineType.Raw && l.Content.TrimStart().StartsWith("."); + + // Instructions whose only register effect is writing operand 1. + private static readonly HashSet WritesOp1Only = new() + { + "MOV", "LDI", "LD", "LDD", "LDS", "IN", "POP", "LPM", "ELPM", "CLR", "SER", "COM", + "NEG", "INC", "DEC", "LSL", "LSR", "ASR", "ROL", "ROR", "SWAP", "ADD", "ADC", "SUB", + "SUBI", "SBC", "SBCI", "AND", "ANDI", "OR", "ORI", "EOR", "BLD", + }; + + // Instructions that touch flags / memory / PC / SP only — no general-purpose + // register destination. + private static readonly HashSet WritesNoReg = new() + { + "CP", "CPC", "CPI", "CPSE", "TST", "ST", "STD", "STS", "OUT", "PUSH", "SBI", "CBI", + "SBIS", "SBIC", "SBRC", "SBRS", "NOP", "RJMP", "JMP", "SEI", "CLI", "WDR", "SLEEP", + "BREAK", "RET", "RETI", "SEC", "CLC", "SEZ", "CLZ", + }; + + // Conservatively over-approximates whether `line` may write register r. Used by the + // redundant-reload pass to decide when a tracked value is still intact, so it MUST + // err toward "yes" (anything unrecognised counts as a write, ending the scan). + private static bool WritesReg(AvrAsmLine line, int r) + { + switch (line.Type) + { + case AvrAsmLine.LineType.Comment: + case AvrAsmLine.LineType.Empty: + case AvrAsmLine.LineType.DebugMarker: + case AvrAsmLine.LineType.Label: + return false; + case AvrAsmLine.LineType.Raw: + return !IsDirectiveLine(line); // unknown hand-written asm -> assume it writes + } + + string m = line.Mnemonic; + if (m.StartsWith("BR")) return false; // conditional/relative branches + if (WritesNoReg.Contains(m)) return false; + // Calls clobber the scratch/arg/return registers; R2-R15 (the globally-unique named-var + // home pool), the Y pointer and the zero register survive (PyMCU never uses those as + // scratch, and the allocator keeps an ISR's homes disjoint from main's). + if (m is "CALL" or "RCALL" or "ICALL" or "EICALL") + return r is not ((>= 2 and <= 15) or 28 or 29 or 1); + if (m is "MUL" or "MULS" or "MULSU" or "FMUL" or "FMULS" or "FMULSU") + return r is 0 or 1; + if (m is "MOVW" or "ADIW" or "SBIW") + { + int d = ParseReg(line.Op1); + return d == r || d + 1 == r; + } + if (WritesOp1Only.Contains(m)) + return ParseReg(line.Op1) == r; + return true; // unrecognised -> conservative + } + + // Removes a `MOV x,y` whose {x,y} both already hold the same value because a prior + // `MOV d,s` (with {x,y} == {d,s}) made them equal and nothing wrote either register + // in between. Stops at any write of d or s and at any label (a possible alternate + // entry where the equality may not hold). + private static void EliminateRedundantReloads(List lines, ref bool changed) + { + for (int i = 0; i < lines.Count; i++) + { + if (lines[i].Type != AvrAsmLine.LineType.Instruction || lines[i].Mnemonic != "MOV") + continue; + int d = ParseReg(lines[i].Op1), s = ParseReg(lines[i].Op2); + if (d < 0 || s < 0 || d == s) continue; + + for (int j = i + 1; j < lines.Count; j++) + { + var lj = lines[j]; + if (lj.Type is AvrAsmLine.LineType.Comment or AvrAsmLine.LineType.Empty + or AvrAsmLine.LineType.DebugMarker) continue; + if (lj.Type == AvrAsmLine.LineType.Label) break; + + if (lj.Type == AvrAsmLine.LineType.Instruction && lj.Mnemonic == "MOV") + { + int x = ParseReg(lj.Op1), y = ParseReg(lj.Op2); + if ((x == d && y == s) || (x == s && y == d)) + { + lines[j] = AvrAsmLine.MakeEmpty(); // copies a value the dest already holds + changed = true; + continue; // {d,s} still equal; keep scanning + } + } + + if (WritesReg(lj, d) || WritesReg(lj, s)) break; + } + } + } + + // Dead pure-write elimination on any register. A pure-write (MOV/LDI/LDD/LDS/IN/POP/ + // CLR/SER/LPM into op1) whose destination is overwritten by another pure-write before any + // read is dead. The forward scan stops — keeping the instruction — at the first read of the + // register, any control-flow (branch/jump/call/ret/skip), label, or raw line, so flow can + // never bypass the overwrite and a call/branch can never observe the value. CLR sets SREG, + // so a dead CLR is removed only when the overwriting write is itself a CLR (re-establishing + // identical flags); the other pure-writes leave SREG untouched, so dropping them is + // flag-neutral. This catches the doubled `CLR Rh` the 16-bit operand widening emits and any + // MOV/LDI/LDD reload of a value that is rewritten before use. + private static void EliminateDeadStores(List lines, ref bool changed) + { + for (int i = 0; i < lines.Count; i++) + { + var li = lines[i]; + if (li.Type != AvrAsmLine.LineType.Instruction || !PureWriteOp1.Contains(li.Mnemonic)) + continue; + int r = ParseReg(li.Op1); + if (r < 0) continue; + if (li.Mnemonic == "MOV" && ParseReg(li.Op2) == r) continue; + bool iSetsFlags = li.Mnemonic == "CLR"; // only CLR (= EOR Rd,Rd) touches SREG here + + for (int j = i + 1; j < lines.Count; j++) + { + var lj = lines[j]; + if (lj.Type is AvrAsmLine.LineType.Comment or AvrAsmLine.LineType.Empty + or AvrAsmLine.LineType.DebugMarker) continue; + if (lj.Type != AvrAsmLine.LineType.Instruction) break; // label / raw -> stop + string m = lj.Mnemonic; + if (m.StartsWith("BR") || m is "RJMP" or "JMP" or "IJMP" or "EIJMP" + or "CALL" or "RCALL" or "ICALL" or "EICALL" or "RET" or "RETI" + or "SBRC" or "SBRS" or "SBIC" or "SBIS" or "CPSE") break; + if (ReadsReg(lj, r)) break; + if (WritesReg(lj, r)) + { + bool jPureWrite = PureWriteOp1.Contains(m) && ParseReg(lj.Op1) == r + && !(m == "MOV" && ParseReg(lj.Op2) == r); + if (jPureWrite && (!iSetsFlags || m == "CLR")) + { + lines[i] = AvrAsmLine.MakeEmpty(); + changed = true; + } + break; + } + } + } + } + + private static bool TryParseImm8(string s, out int value) + { + value = 0; + s = s.Trim(); + bool ok = s.StartsWith("0x") || s.StartsWith("0X") + ? int.TryParse(s.AsSpan(2), System.Globalization.NumberStyles.HexNumber, null, out value) + : int.TryParse(s, out value); + return ok && value is >= 0 and <= 0xFF; + } + + // Conservatively over-approximates whether `line` may read register r. Must err + // toward "yes": any unrecognised instruction is assumed to read r so the + // park-round-trip pass never reorders a value past a hidden use. + private static bool ReadsReg(AvrAsmLine line, int r) + { + if (line.Type is AvrAsmLine.LineType.Comment or AvrAsmLine.LineType.Empty + or AvrAsmLine.LineType.DebugMarker or AvrAsmLine.LineType.Label) + return false; + if (line.Type == AvrAsmLine.LineType.Raw) + return !IsDirectiveLine(line); + string m = line.Mnemonic; + int op1 = ParseReg(line.Op1), op2 = ParseReg(line.Op2); + if (m is "CALL" or "RCALL" or "ICALL" or "EICALL") return r is >= 18 and <= 25; // PyMCU arg regs + if (m is "RET" or "RETI") return r is 24 or 25; // return value + if (m.StartsWith("BR") || m is "RJMP" or "JMP" or "NOP" or "SEI" or "CLI" + or "WDR" or "SLEEP" or "SEC" or "CLC" or "SEZ" or "CLZ") return false; + if (m is "LDI" or "CLR" or "SER" or "LDS" or "IN" or "POP") return false; // pure dest, no GP read + if (m == "MOV") return op2 == r; + if (m == "MOVW") return op2 == r || op2 + 1 == r; + if (m is "LD" or "LDD") return r is >= 26 and <= 31; // pointer (X/Y/Z) + if (m is "LPM" or "ELPM") return r is 30 or 31; // Z + if (m is "ST" or "STD") return op2 == r || r is >= 26 and <= 31; // value + pointer + if (m is "STS" or "OUT") return op2 == r; // value + if (m == "PUSH") return op1 == r; + if (m is "ADIW" or "SBIW") return op1 == r || op1 + 1 == r; + return op1 == r || op2 == r; // ALU / compare / shift / unrecognised -> reads its operands + } + + // A materialized variable-index array address living in Z (R30:R31), as emitted by + // CompileArrayLoad/Store: an index load into R24, an optional LSL (16-bit scale), + // then LDI R30,c0; LDI R31,c1; ADD R30,R24; ADC R31,R1. The following LD/ST reads + // Z without modifying it, so Z stays valid until the index source or Z is touched. + private readonly struct ZBlock + { + public readonly int Start; // line index of the index load (first line) + public readonly int End; // line index of the ADC R31,R1 (last line) + public readonly int SrcReg; // index source register (MOV idxload), else -1 + public readonly int SrcSlot; // index source Y+offset (LDD idxload), else -1 + public readonly string Recipe;// canonical signature: idxload|lsl|c0|c1 + public ZBlock(int start, int end, int srcReg, int srcSlot, string recipe) + => (Start, End, SrcReg, SrcSlot, Recipe) = (start, end, srcReg, srcSlot, recipe); + } + + // Parses the 5/6-line Z-address recipe starting at significant line `i`. The index + // scratch is always R24 (LoadIntoReg(index, "R24")); the LSL is present only for + // 2-byte elements. Returns false unless the whole canonical shape is present. + private static bool TryParseZBlock(List lines, int i, out ZBlock block) + { + block = default; + var idx = lines[i]; + if (idx.Type != AvrAsmLine.LineType.Instruction || idx.Op1 != "R24") return false; + int srcReg = -1, srcSlot = -1; + if (idx.Mnemonic == "MOV") { srcReg = ParseReg(idx.Op2); if (srcReg < 0) return false; } + else if (idx.Mnemonic == "LDD") { srcSlot = ParseYOffset(idx.Op2); if (srcSlot < 0) return false; } + else return false; + + int j = NextSignificant(lines, i); + if (j < 0) return false; + bool hasLsl = lines[j].Type == AvrAsmLine.LineType.Instruction + && lines[j].Mnemonic == "LSL" && lines[j].Op1 == "R24"; + if (hasLsl) { j = NextSignificant(lines, j); if (j < 0) return false; } + + var ldi0 = lines[j]; + if (ldi0.Type != AvrAsmLine.LineType.Instruction || ldi0.Mnemonic != "LDI" || ldi0.Op1 != "R30") + return false; + int j1 = NextSignificant(lines, j); + if (j1 < 0) return false; + var ldi1 = lines[j1]; + if (ldi1.Type != AvrAsmLine.LineType.Instruction || ldi1.Mnemonic != "LDI" || ldi1.Op1 != "R31") + return false; + int j2 = NextSignificant(lines, j1); + if (j2 < 0) return false; + var add = lines[j2]; + if (add.Type != AvrAsmLine.LineType.Instruction || add.Mnemonic != "ADD" + || add.Op1 != "R30" || add.Op2 != "R24") return false; + int j3 = NextSignificant(lines, j2); + if (j3 < 0) return false; + var adc = lines[j3]; + if (adc.Type != AvrAsmLine.LineType.Instruction || adc.Mnemonic != "ADC" + || adc.Op1 != "R31" || adc.Op2 != "R1") return false; + + string recipe = $"{idx.Mnemonic}|{idx.Op2}|{hasLsl}|{ldi0.Op2}|{ldi1.Op2}"; + block = new ZBlock(i, j3, srcReg, srcSlot, recipe); + return true; + } + + // True if `line` may invalidate the standing Z address `z`: a control-flow boundary + // (label/jump/branch/RET, or a CALL — which clobbers Z per WritesReg), a write to Z + // itself, a write to the index source register, or — for a slot-sourced index — any + // memory store that might alias the slot. Conservative: the same path-divergence + // discipline as RegDeadAfter, so a stale Z is never reused across a join. + private static bool InvalidatesZ(AvrAsmLine line, ZBlock z) + { + switch (line.Type) + { + case AvrAsmLine.LineType.Label: return true; + case AvrAsmLine.LineType.Raw: return !IsDirectiveLine(line); + case AvrAsmLine.LineType.Instruction: break; + default: return false; + } + string m = line.Mnemonic; + if (m is "RJMP" or "JMP" or "IJMP" or "EIJMP" or "RET" or "RETI" || m.StartsWith("BR")) + return true; + if (WritesReg(line, 30) || WritesReg(line, 31)) return true; // Z corrupted (incl. CALL) + if (z.SrcReg >= 0 && WritesReg(line, z.SrcReg)) return true; // index changed + if (z.SrcSlot >= 0 && m is "STD" or "ST" or "STS") return true; // slot may be overwritten + return false; + } + + // Drops the second materialization of an array address that Z already holds. Walks + // the stream tracking the live Z recipe; when an identical recipe is rebuilt while + // the index source and Z are provably intact (the walk nulls the fact at any + // invalidating line), the rebuild — index load, optional LSL, two LDIs, ADD, ADC — + // is deleted. The following LD/ST keeps using the address still sitting in Z. + private static void EliminateRedundantZSetup(List lines, ref bool changed) + { + ZBlock? live = null; + for (int i = 0; i < lines.Count; i++) + { + var t = lines[i].Type; + if (t is AvrAsmLine.LineType.Comment or AvrAsmLine.LineType.Empty + or AvrAsmLine.LineType.DebugMarker) + continue; + + if (TryParseZBlock(lines, i, out ZBlock cur)) + { + if (live is ZBlock p && p.Recipe == cur.Recipe) + { + for (int k = cur.Start; k <= cur.End; k++) + lines[k] = AvrAsmLine.MakeEmpty(); + int c = cur.Start - 1; // drop the "...index via Z" comment too + if (c >= 0 && lines[c].Type == AvrAsmLine.LineType.Comment) + lines[c] = AvrAsmLine.MakeEmpty(); + changed = true; + i = cur.End; // Z still holds p's address; keep `live` + continue; + } + live = cur; + i = cur.End; + continue; + } + + if (live is ZBlock z && InvalidatesZ(lines[i], z)) + live = null; + } + } + + // Collapses a park/unpark round-trip the call-argument setup leaves behind: + // MOV Rh, Rs ; ; MOV Rd, Rh ; CALL + // becomes + // MOV Rd, Rs ; ; CALL + // moving the value straight into its final register instead of stashing it in a + // home and reloading it. Sound only when, between the two MOVs, nothing reads or + // writes Rh or Rd and there is no call/branch/label (so reordering is safe), and + // Rh is dead after the unpark (so dropping its definition loses nothing). + private static void EliminateParkRoundTrip(List lines, ref bool changed) + { + for (int i = 0; i < lines.Count; i++) + { + if (lines[i].Type != AvrAsmLine.LineType.Instruction || lines[i].Mnemonic != "MOV") + continue; + int rh = ParseReg(lines[i].Op1), rs = ParseReg(lines[i].Op2); // park: MOV Rh, Rs + if (rh < 0 || rs < 0 || rh == rs) continue; + + for (int j = i + 1; j < lines.Count; j++) + { + var lj = lines[j]; + if (lj.Type is AvrAsmLine.LineType.Comment or AvrAsmLine.LineType.Empty + or AvrAsmLine.LineType.DebugMarker) continue; + if (lj.Type != AvrAsmLine.LineType.Instruction) break; // label / raw -> bail + string m = lj.Mnemonic; + + if (m == "MOV" && ParseReg(lj.Op2) == rh) // candidate unpark MOV Rd, Rh + { + int rd = ParseReg(lj.Op1); + if (rd >= 0 && rd != rh && rd != rs + && !RegTouchedBetween(lines, i, j, rd) + && RegDeadAfter(lines, j, rh)) + { + lines[i] = AvrAsmLine.MakeInstruction("MOV", "R" + rd, "R" + rs); + lines[j] = AvrAsmLine.MakeEmpty(); + changed = true; + } + break; // first unpark candidate decides; stop scanning this park + } + + // Reordering is unsafe across control flow, and Rh must stay intact. + if (m is "CALL" or "RCALL" or "ICALL" or "EICALL" or "RET" or "RETI" + or "RJMP" or "JMP" || m.StartsWith("BR")) break; + if (ReadsReg(lj, rh) || WritesReg(lj, rh)) break; + } + } + } + + // True if register r is read or written by any instruction strictly between i and j. + private static bool RegTouchedBetween(List lines, int i, int j, int r) + { + for (int k = i + 1; k < j; k++) + if (ReadsReg(lines[k], r) || WritesReg(lines[k], r)) return true; + return false; + } + + // True if register r is provably dead after index j: scanning the straight-line + // continuation, it is redefined (written) before any read. The reasoning only + // holds without path divergence, so this bails (returns false) at any branch, + // jump, label, RET, or non-directive raw asm — a write seen past a conditional + // branch does not redefine r on the not-taken path (e.g. a `min = x` guarded by + // `BRSH`). A plain CALL is transparent: it returns to the next instruction and, + // per ReadsReg/WritesReg, only touches the argument/scratch registers. + private static bool RegDeadAfter(List lines, int j, int r) + { + for (int k = j + 1; k < lines.Count; k++) + { + var lk = lines[k]; + if (lk.Type is AvrAsmLine.LineType.Comment or AvrAsmLine.LineType.Empty + or AvrAsmLine.LineType.DebugMarker) continue; + if (lk.Type == AvrAsmLine.LineType.Raw) + { + if (IsDirectiveLine(lk)) continue; // assembler metadata: no register effect + return false; // hand-written asm: unknown effects -> bail + } + if (lk.Type == AvrAsmLine.LineType.Label) return false; + string m = lk.Mnemonic; + if (m is "RET" or "RETI" or "RJMP" or "JMP" or "IJMP" or "EIJMP" || m.StartsWith("BR")) + return false; // path divergence -> linear reasoning unsound + if (ReadsReg(lk, r)) return false; // used before redefinition -> live + if (WritesReg(lk, r)) return true; // redefined first -> dead + } + return false; + } + + // Inverse pairs for the AVR status-flag conditional branches. EmitBranch lowers a + // "branch to target if cond" as `inv(cond) skip; RJMP target; skip:` so the RJMP can + // reach any distance. When target is within a conditional branch's own ±64-word reach, + // the whole triple collapses back to a single `cond target`. + private static readonly Dictionary BranchInverse = new() + { + { "BREQ", "BRNE" }, { "BRNE", "BREQ" }, { "BRCS", "BRCC" }, { "BRCC", "BRCS" }, + { "BRSH", "BRLO" }, { "BRLO", "BRSH" }, { "BRLT", "BRGE" }, { "BRGE", "BRLT" }, + { "BRMI", "BRPL" }, { "BRPL", "BRMI" }, { "BRVS", "BRVC" }, { "BRVC", "BRVS" }, + { "BRHS", "BRHC" }, { "BRHC", "BRHS" }, { "BRTS", "BRTC" }, { "BRTC", "BRTS" }, + { "BRIE", "BRID" }, { "BRID", "BRIE" }, + }; + + // Word size of a line for branch-distance accounting. Returns false for anything whose + // size (or effect on the address counter) we cannot account for — Raw inline-asm and + // assembler directives — so a candidate whose span contains one is left untouched. + private static bool TryWordSize(AvrAsmLine ln, out int words) + { + words = 0; + switch (ln.Type) + { + case AvrAsmLine.LineType.Instruction: + words = ln.Mnemonic is "CALL" or "JMP" or "LDS" or "STS" ? 2 : 1; + return true; + case AvrAsmLine.LineType.Label: + case AvrAsmLine.LineType.Comment: + case AvrAsmLine.LineType.Empty: + case AvrAsmLine.LineType.DebugMarker: + return true; // occupy no flash + case AvrAsmLine.LineType.Raw: + return TryRawWordSize(ln.Content, out words); + default: + return false; + } + } + + // Size a Raw block (e.g. an inline-asm body emitted as one multi-line string) by counting + // its instruction words. Returns false on anything that emits data or moves the origin + // (.org/.byte/.word/...) — those we cannot account for in a relative distance, so the + // candidate spanning them is left untouched. + private static bool TryRawWordSize(string content, out int words) + { + words = 0; + foreach (var rawLine in content.Split('\n')) + { + var line = rawLine; + int sc = line.IndexOf(';'); + if (sc >= 0) line = line[..sc]; + line = line.Trim(); + if (line.Length == 0 || line.EndsWith(":")) continue; // blank / label + + string head = line.Split(new[] { ' ', '\t', ',' }, 2, + StringSplitOptions.RemoveEmptyEntries)[0]; + if (head.StartsWith(".")) + { + switch (head.ToLowerInvariant()) + { + case ".equ": case ".set": case ".global": case ".extern": + case ".def": case ".undef": case ".list": case ".nolist": + case ".cseg": case ".dseg": + continue; // zero-size assembler metadata + default: + return false; // .org / data directive / unknown + } + } + words += head.ToUpperInvariant() is "CALL" or "JMP" or "LDS" or "STS" ? 2 : 1; + } + return true; + } + + private static int NextSignificant(List lines, int idx) + { + for (int x = idx + 1; x < lines.Count; ++x) + if (lines[x].Type is not (AvrAsmLine.LineType.Comment + or AvrAsmLine.LineType.Empty + or AvrAsmLine.LineType.DebugMarker)) + return x; + return -1; + } + + private static int CountLabelRefs(List lines, string label) + { + int c = 0; + foreach (var ln in lines) + if (ln.Type == AvrAsmLine.LineType.Instruction && ln.Op1 == label + && (ln.Mnemonic.StartsWith("BR") || ln.Mnemonic is "RJMP" or "JMP" or "RCALL" or "CALL")) + ++c; + return c; + } + + // Branch displacement k in words, where the target is reached as PC+1+k. Returns null + // when the span between branch and target contains an unsizable line. + private static int? BranchDisplacement(List lines, int branchIdx, int targetIdx) + { + int sum = 0; + if (targetIdx > branchIdx) + { + for (int x = branchIdx + 1; x < targetIdx; ++x) + { + if (!TryWordSize(lines[x], out int w)) return null; + sum += w; + } + return sum; // forward + } + for (int x = targetIdx; x < branchIdx; ++x) + { + if (!TryWordSize(lines[x], out int w)) return null; + sum += w; + } + return -sum - 1; // backward + } + + // Collapse `inv(cond) skip; RJMP target; skip:` into `cond target` whenever target is + // within the conditional branch's ±64-word reach. Shortening only ever reduces the + // distance seen by other branches, so the fixed-point loop is monotone and a candidate + // validated in the current (longer) layout stays in range after every later removal. + // If a displacement is mis-estimated as in-range, the assembler rejects the build — a + // loud, test-caught failure, never silently wrong code. + private static void ShortenBranches(List lines) + { + bool changed = true; + while (changed) + { + changed = false; + for (int i = 0; i < lines.Count; ++i) + { + var br = lines[i]; + if (br.Type != AvrAsmLine.LineType.Instruction) continue; + if (!BranchInverse.ContainsKey(br.Mnemonic)) continue; + + int j = NextSignificant(lines, i); + if (j < 0 || lines[j].Mnemonic != "RJMP") continue; + int k = NextSignificant(lines, j); + if (k < 0 || lines[k].Type != AvrAsmLine.LineType.Label) continue; + if (lines[k].LabelText != br.Op1) continue; // the skip label + if (CountLabelRefs(lines, br.Op1) != 1) continue; // referenced only here + + string target = lines[j].Op1; + int targetIdx = lines.FindIndex(l => l.Type == AvrAsmLine.LineType.Label + && l.LabelText == target); + if (targetIdx < 0) continue; + + int? disp = BranchDisplacement(lines, i, targetIdx); + if (disp is null or < -64 or > 63) continue; + + lines[i] = AvrAsmLine.MakeInstruction(BranchInverse[br.Mnemonic], target); + lines.RemoveAt(k); // remove higher index first + lines.RemoveAt(j); + changed = true; + break; // restart: indices and distances have shifted + } + } + } + + // Registers the linear-scan allocator uses as short-lived temporaries (never + // call-saved/restored, never a return register). A MOV that writes one of these + // and is never read afterwards is the home-store of a call result the comparison + // already consumed from R24 -- pure dead weight. + private static readonly int[] TempRegs = { 16, 17 }; + + // Mnemonics whose first operand is a pure destination (written, not read). + private static readonly HashSet PureWriteOp1 = new() + { + "MOV", "LDI", "LDD", "LDS", "IN", "POP", "CLR", "SER", "LPM", "ELPM", + }; + + /// + /// Removes MOV R16/R17, Rs instructions whose destination is dead — never + /// read on any path before being overwritten or before the function returns. + /// + /// Uses a backward liveness restricted to the two temporary registers, which makes + /// the exit condition exact (R16/R17 are scratch: not return values and, if ever + /// pushed, restored by a POP that kills the value first, so they are dead at every + /// RET). Safety is asymmetric by design: reads are over-approximated (unknown + /// mnemonics, raw inline asm and calls are treated as reading the temps, keeping + /// them live) while writes are under-approximated (only an unambiguous pure write + /// kills liveness). The pass bails entirely on computed jumps it cannot resolve. + /// + private static void EliminateDeadTempMoves(List lines) + { + int n = lines.Count; + if (n == 0) return; + + var labelIndex = new Dictionary(); + for (int i = 0; i < n; i++) + if (lines[i].Type == AvrAsmLine.LineType.Label) + labelIndex[lines[i].LabelText] = i; + + // Successors per line. null entry => bail (unresolved control flow). + var succ = new List[n]; + for (int i = 0; i < n; i++) + { + var line = lines[i]; + if (line.Type != AvrAsmLine.LineType.Instruction) + { + succ[i] = i + 1 < n ? new List { i + 1 } : new List(); + continue; + } + + string m = line.Mnemonic; + if (m is "RET" or "RETI") + { + succ[i] = new List(); // exit: temps are dead here + } + else if (m is "RJMP" or "JMP") + { + if (!labelIndex.TryGetValue(line.Op1, out int t)) return; // unknown target -> bail + succ[i] = new List { t }; + } + else if (m is "IJMP" or "EIJMP") + { + return; // computed jump -> bail + } + else if (m.StartsWith("BR")) + { + var s = new List(); + if (labelIndex.TryGetValue(line.Op1, out int t)) s.Add(t); + else return; // unknown branch target -> bail + if (i + 1 < n) s.Add(i + 1); + succ[i] = s; + } + else + { + // Everything else (incl. CALL/RCALL/ICALL) falls through. + succ[i] = i + 1 < n ? new List { i + 1 } : new List(); + } + } + + // Raw lines may be hand-written asm with arbitrary register effects; treat them + // as reading the temps so nothing around them is touched. + // Assembler directives (.equ/.org/.byte/.global/...) emit no code and touch no + // register; only hand-written inline-asm Raw lines have unknown register effects. + static bool IsDirective(AvrAsmLine l) + => l.Type == AvrAsmLine.LineType.Raw && l.Content.TrimStart().StartsWith("."); + + bool Reads(AvrAsmLine line, int r) + { + if (line.Type == AvrAsmLine.LineType.Raw) + // Hand-written inline asm: assume it reads the temp only if the register + // name appears in its text (so register-free Raw like NOP delays stay + // transparent). Over-approximate — any textual mention counts as a read. + return !IsDirective(line) + && line.Content.Contains("R" + r, StringComparison.OrdinalIgnoreCase); + if (line.Type != AvrAsmLine.LineType.Instruction) return false; + string m = line.Mnemonic; + // CALL/RCALL/ICALL do NOT read the temp registers: PyMCU passes arguments in + // R24/R22/R20/R18 (never R16/R17), and callees that use a temp internally + // (e.g. __div8) push/pop it rather than taking it as input. The operand of a + // call is a label, so it reads no register here either way. + if (PureWriteOp1.Contains(m)) return ParseReg(line.Op2) == r; // Op1 is the destination + return ParseReg(line.Op1) == r || ParseReg(line.Op2) == r; + } + + bool PureWrites(AvrAsmLine line, int r) + { + if (line.Type != AvrAsmLine.LineType.Instruction) return false; + return PureWriteOp1.Contains(line.Mnemonic) + && ParseReg(line.Op1) == r + && ParseReg(line.Op2) != r; + } + + // Backward liveness fixpoint, one bit per temp register. + int t0 = TempRegs.Length; + var liveIn = new bool[n, t0]; + bool changed = true; + while (changed) + { + changed = false; + for (int i = n - 1; i >= 0; i--) + { + for (int ti = 0; ti < t0; ti++) + { + int r = TempRegs[ti]; + bool outLive = false; + foreach (int s in succ[i]) outLive |= liveIn[s, ti]; + bool inLive = Reads(lines[i], r) || (!PureWrites(lines[i], r) && outLive); + if (inLive != liveIn[i, ti]) { liveIn[i, ti] = inLive; changed = true; } + } + } + } + + // Remove MOV , Rs whose destination is dead on exit. + for (int i = 0; i < n; i++) + { + var line = lines[i]; + if (line.Type != AvrAsmLine.LineType.Instruction || line.Mnemonic != "MOV") continue; + int dst = ParseReg(line.Op1); + int src = ParseReg(line.Op2); + int ti = Array.IndexOf(TempRegs, dst); + if (ti < 0 || src < 0 || src == dst) continue; + + bool outLive = false; + foreach (int s in succ[i]) outLive |= liveIn[s, ti]; + if (!outLive) lines[i] = AvrAsmLine.MakeEmpty(); + } + } } \ No newline at end of file diff --git a/src/csharp/lib/Targets/AVR/AvrRegisterAllocator.cs b/src/csharp/lib/Targets/AVR/AvrRegisterAllocator.cs index acb9c082..45d1edb9 100644 --- a/src/csharp/lib/Targets/AVR/AvrRegisterAllocator.cs +++ b/src/csharp/lib/Targets/AVR/AvrRegisterAllocator.cs @@ -32,6 +32,27 @@ public static Dictionary Allocate(ProgramIR program) var useCount = new Dictionary(); var varTypes = new Dictionary(); + // First pass: collect names that MUST live in SRAM because some codegen + // path resolves them by address rather than through _regLayout. Registerizing + // any of these would desynchronize address-based and register-based accesses. + // - GcRoot/GcUnroot push the variable's absolute SRAM address onto the shadow + // stack (GetGcRefSramAddr throws if the name is not in the stack layout). + // - Bytearray / array base names are dereferenced via their SRAM offset. + var unsafeNames = new HashSet(); + foreach (var instr in program.Functions.SelectMany(func => func.Body)) + { + switch (instr) + { + case GcRoot gr when NameOf(gr.Var) is { } gn: unsafeNames.Add(gn); break; + case GcUnroot gu when NameOf(gu.Var) is { } un: unsafeNames.Add(un); break; + case BytearrayLoad bl: unsafeNames.Add(bl.PtrName); break; + case BytearrayStore bs: unsafeNames.Add(bs.PtrName); break; + case ArrayLoad al: unsafeNames.Add(al.ArrayName); break; + case ArrayStore ast: unsafeNames.Add(ast.ArrayName); break; + case ArrayLoadFlash alf: unsafeNames.Add(alf.ArrayName); break; + } + } + foreach (var instr in program.Functions.SelectMany(func => func.Body)) { switch (instr) @@ -108,18 +129,46 @@ public static Dictionary Allocate(ProgramIR program) CountVal(ast.Index); CountVal(ast.Src); break; + // IndirectCall/GcAlloc were absent, so a VARIABLE that is an indirect call's + // result/args/target or a GC allocation's result/size was never counted and so + // never won an R2-R15 home (it fell to a stack slot). Counting them lets such + // variables be register-homed; R2-R15 are callee-saved, so they survive the call. + case IndirectCall ic: + CountVal(ic.FuncAddr); + foreach (var a in ic.Args) CountVal(a); + CountVal(ic.Dst); + break; + case GcAlloc ga: + CountVal(ga.Size); + CountVal(ga.Dst); + break; } } - // Collect eligible: only UINT8/UINT16 (1-2 bytes). Exclude UINT32+ because the - // global allocator has no callee-save/restore, so multi-byte values spanning a - // function call boundary would be clobbered by the callee's own register usage. + // Collect eligible: only UINT8/UINT16/INT8/INT16 (1-2 bytes). Exclude: + // - UINT32+/FLOAT (multi-byte; not handled by this fixed R4-R15 layout here). + // - GC_REF/FUNCREF pointers (need an address / shadow-stack handling). + // - unsafe names that some path resolves by SRAM address (see first pass). + // Note: R4-R15 are NEVER used as codegen scratch, and this allocator assigns a + // globally-unique register per name. So a value in R4-R15 survives any call + // (the callee's own named vars get different registers; leaf scratch is R16-R27). + // That invariant — not a DotCount heuristic — is what makes cross-call safety hold, + // so inline-expanded locals (dotted names) are eligible too. var sorted = useCount - .Where(kv => varTypes.TryGetValue(kv.Key, out var dt) && SizeOfType(dt) <= 2) - .OrderByDescending(kv => kv.Value).ToList(); + .Where(kv => varTypes.TryGetValue(kv.Key, out var dt) + && SizeOfType(dt) <= 2 + && dt != DataType.GC_REF && dt != DataType.FUNCREF + && !unsafeNames.Contains(kv.Key)) + .OrderByDescending(kv => kv.Value) + .ThenBy(kv => kv.Key, StringComparer.Ordinal).ToList(); var result = new Dictionary(); - var nextReg = 4; + // R2-R15 are the callee-saved home pool. R2/R3 are otherwise unused by the codegen + // (which stages through R0/R1 and R16-R27) and by the math runtime, and — like R4-R15 + // — the allocator's unique-per-name guarantee keeps an ISR's homes disjoint from main's, + // so they need no context-save. Including them gives two more register homes program-wide, + // displacing the lowest-priority spills (each frame access is a 2-word LDD/STD). + var nextReg = 2; foreach (var (name, _) in sorted) { @@ -132,12 +181,16 @@ public static Dictionary Allocate(ProgramIR program) return result; - static int DotCount(string s) => s.Count(c => c == '.'); + static string? NameOf(Val val) => val switch + { + Variable v => v.Name, + Temporary t => t.Name, + _ => null, + }; void CountVal(Val val) { if (val is not Variable v) return; - if (DotCount(v.Name) >= 2) return; useCount.TryGetValue(v.Name, out int count); useCount[v.Name] = count + 1; varTypes[v.Name] = v.Type; diff --git a/src/python/pymcu/backend/avr/plugin.py b/src/python/pymcu/backend/avr/plugin.py index 8643b83f..b51b40f1 100644 --- a/src/python/pymcu/backend/avr/plugin.py +++ b/src/python/pymcu/backend/avr/plugin.py @@ -85,15 +85,43 @@ def get_backend_binary(cls) -> Path: @classmethod def _ensure_signed(cls, binary: Path) -> None: """Ad-hoc sign the binary on macOS (no-op on other platforms or if already signed). - Native AOT .NET binaries are unsigned by default; macOS kills unsigned executables.""" + Native AOT .NET binaries are unsigned by default; macOS kills unsigned executables. + + This runs on every backend resolution, including under parallel builds (the + test suite spawns many ``pymcu build`` processes at once). A bare + ``codesign --force`` rewrites the Mach-O in place, so two processes signing the + same binary concurrently race and corrupt its header (a flipped magic byte -> + ``Exec format error`` on the next exec). To stay safe we (1) verify first and + skip when already validly signed, and (2) serialize the rare signing step with + an exclusive lock so only one process ever writes the binary at a time.""" if sys.platform != "darwin" or not binary.exists(): return import subprocess + + def _is_signed() -> bool: + try: + return subprocess.run( + ["codesign", "--verify", "--strict", str(binary)], + check=False, capture_output=True, + ).returncode == 0 + except FileNotFoundError: + return True # no codesign tool -> nothing we can (or need to) do + + if _is_signed(): + return + + import fcntl + lock_path = Path(str(binary) + ".signlock") try: - subprocess.run( - ["codesign", "-s", "-", "--force", str(binary)], - check=False, capture_output=True - ) + with open(lock_path, "w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + # Re-check under the lock: another process may have signed it while we waited. + if _is_signed(): + return + subprocess.run( + ["codesign", "-s", "-", "--force", str(binary)], + check=False, capture_output=True, + ) except FileNotFoundError: pass diff --git a/src/python/pymcu/toolchain/avr/avrgas.py b/src/python/pymcu/toolchain/avr/avrgas.py index fc86cf4e..2153e19d 100644 --- a/src/python/pymcu/toolchain/avr/avrgas.py +++ b/src/python/pymcu/toolchain/avr/avrgas.py @@ -426,19 +426,19 @@ def _org_to_bytes(m: "_re.Match[str]") -> str: # AVRA labels are word-addressed; GNU AS labels are byte-addressed. # "label * 2" (word→byte conversion) must be removed for GNU AS. line = _re.sub(r"\b(hi8|lo8)\((\w+)\s*\*\s*2\)", r"\1(\2)", line) - # RCALL → CALL - # avr-ld may generate R_AVR_13_PCREL relocations for RCALL that - # overflow when calling external C symbols in FFI builds. - # Upgrade RCALL unconditionally to the 2-word CALL so the linker - # never truncates a relocation. - # - # RJMP is intentionally NOT converted to JMP: the vector table - # uses RJMP+NOP (4 bytes per slot) and the .org spacing is also - # 4 bytes; converting to JMP (4 bytes) + NOP would make each used - # slot 6 bytes and the next .org would move backwards. RJMP range - # is ±2047 words which is sufficient for all targets within a - # single assembly file. + # RCALL → CALL and RJMP → JMP. + # RCALL/RJMP carry 13-bit PC-relative relocations (±2047 words ≈ ±4 KB). + # That is enough for small programs but a large one (or an FFI call to a + # distant C symbol) overflows them — "relocation truncated to fit: + # R_AVR_13_PCREL". Emitting the unconditional 2-word forms (CALL/JMP, + # 22-bit absolute, full address space) is always safe; the linker's + # relaxation (-mrelax, added to the link command) shrinks each one back + # to RCALL/RJMP wherever the target is in range, so small programs keep + # the compact encoding while large ones still link. The atmega-style + # vector table is 4-byte-per-slot (.org spacing), which a 4-byte JMP + # fills exactly. line = _re.sub(r"\bRCALL\b", "CALL", line) + line = _re.sub(r"\bRJMP\b", "JMP", line) # .db ... → .byte ... line = _re.sub(r"^\s*\.db\b", ".byte", line) @@ -607,6 +607,7 @@ def link( # ld/as/collect2 instead of any system avr-binutils installation. f"-B{_gcc_bin_path}", f"-mmcu={self.chip}", + "-mrelax", # relax CALL/JMP -> RCALL/RJMP where the target is in range "-nostartfiles", # our assembly provides the entry point; skip crt0.o "-nodefaultlibs", # suppress spec-driven -lc/-latmega328p (avr-gcc 15.x) "-T", str(linker_script), diff --git a/tests/integration/AllocStressGenerator.cs b/tests/integration/AllocStressGenerator.cs new file mode 100644 index 00000000..8431a4ed --- /dev/null +++ b/tests/integration/AllocStressGenerator.cs @@ -0,0 +1,154 @@ +namespace PyMCU.IntegrationTests; + +/// +/// Deterministic generator of register-allocator stress programs for property/differential +/// testing. Each seed yields a self-contained PyMCU program that: +/// * reads ONE seed byte at runtime (uart.read_blocking()) — so every value is a +/// runtime value the optimizer cannot constant-fold, which is what makes the program +/// actually exercise the register allocator rather than fold to printed constants, +/// * derives a pool of uint8 and uint16 locals from that seed (more than the register file +/// holds, forcing allocation / spilling decisions), +/// * runs a straight-line sequence of arithmetic/bitwise statements over those locals +/// (so a clobbered or mis-homed register yields a wrong value), +/// * threads several values through real (non-@inline) function calls, exercising the +/// call-spanning case allocator bugs historically broke (a value read as 0), and +/// * prints every final value, one decimal per line. +/// +/// The generator simultaneously evaluates the same operations in C# with PyMCU's exact +/// fixed-width wrapping semantics (no integer promotion: uint8 OP uint8 wraps to +/// uint8) for the injected seed byte, producing the oracle. A mismatch +/// between the simulated output and Expected is a codegen/allocator bug. This is the safety +/// net to run before and after the register-allocator redesign (codegen backlog A17/A31). +/// +public sealed class AllocStressProgram +{ + public string Source { get; } + /// The seed byte the test must inject over UART after the "GO" banner. + public byte InputByte { get; } + /// Expected printed decimals, in print order. + public IReadOnlyList Expected { get; } + + private AllocStressProgram(string source, byte input, List expected) + => (Source, InputByte, Expected) = (source, input, expected); + + private const int NumU8 = 12; // exceeds the R4-R15 (12) + R16/R17 register budget + private const int NumU16 = 6; + private const int NumStmts = 44; + private const int NumHelpers = 3; + + public static AllocStressProgram Generate(int seed) + { + var rng = new Random(seed); + byte input = (byte)(seed * 37 + 11); // the runtime seed byte the test injects + var src = new System.Text.StringBuilder(); + + // ── Reference state (computed from the injected seed byte `s`) ───── + int s = input; + var u8 = new int[NumU8]; + var u16 = new int[NumU16]; + + // Per-var initializer recipe derived from `s` (runtime → not constant-folded). + var u8InitOp = new string[NumU8]; var u8InitC = new int[NumU8]; + var u16InitOp = new string[NumU16]; var u16InitC = new int[NumU16]; + string[] initOps = { "+", "-", "*", "^", "&", "|" }; + for (int i = 0; i < NumU8; i++) { u8InitOp[i] = initOps[rng.Next(initOps.Length)]; u8InitC[i] = rng.Next(0, 256); u8[i] = ApplyU8(s, u8InitOp[i], u8InitC[i]); } + for (int i = 0; i < NumU16; i++) { u16InitOp[i] = initOps[rng.Next(initOps.Length)]; u16InitC[i] = rng.Next(0, 65536); u16[i] = ApplyU16(s, u16InitOp[i], u16InitC[i]); } + + // Three helpers of deliberately varied SHAPE, to exercise the paths a callee-save + // register allocator must get right (codegen backlog A33): + // h0 — leaf, single return. + // h1 — MULTIPLE return paths (each exit must restore any callee-saved register). + // h2 — NESTED call (calls h0): h0's frame must preserve h2's live values, and h2's + // live values must survive across the call to h0. + // Constants are fixed per seed; the reference Helper() below mirrors each exactly. + var hA = new int[NumHelpers]; var hB = new int[NumHelpers]; + for (int k = 0; k < NumHelpers; k++) { hA[k] = rng.Next(0, 256); hB[k] = rng.Next(0, 256); } + int Helper(int k, int x) => k switch + { + 0 => (((x + hA[0]) & 0xFF) ^ hB[0]) & 0xFF, + 1 => (x & 0x40) != 0 ? (x + hA[1]) & 0xFF : (x ^ hB[1]) & 0xFF, + _ => x > 0x80 ? (Helper(0, x) - hA[2]) & 0xFF : (Helper(0, x) + hB[2]) & 0xFF, + }; + + // ── Source: helpers (non-@inline → real CALL → call-spanning) ────── + src.Append("from pymcu.types import uint8, uint16\n"); + src.Append("from pymcu.hal.uart import UART\n\n\n"); + src.Append($"def h0(x: uint8) -> uint8:\n return ((x + {hA[0]}) ^ {hB[0]})\n\n\n"); + src.Append("def h1(x: uint8) -> uint8:\n"); + src.Append($" if (x & 64) > 0:\n return x + {hA[1]}\n return x ^ {hB[1]}\n\n\n"); + src.Append("def h2(x: uint8) -> uint8:\n"); + src.Append($" if x > 128:\n return h0(x) - {hA[2]}\n return h0(x) + {hB[2]}\n\n\n"); + + src.Append("def main():\n"); + src.Append(" uart = UART(9600)\n"); + src.Append(" uart.println(\"GO\")\n"); + src.Append(" s: uint8 = uart.read_blocking()\n"); + for (int i = 0; i < NumU8; i++) src.Append($" a{i}: uint8 = s {u8InitOp[i]} {u8InitC[i]}\n"); + for (int i = 0; i < NumU16; i++) src.Append($" b{i}: uint16 = uint16(s) {u16InitOp[i]} {u16InitC[i]}\n"); + + // ── Statement sequence ───────────────────────────────────────────── + string[] ops = { "+", "-", "*", "&", "|", "^" }; + for (int stmt = 0; stmt < NumStmts; stmt++) + { + int choice = rng.Next(0, 10); + if (choice < 4) + { + int k = rng.Next(NumU8), a = rng.Next(NumU8); + string op = ops[rng.Next(ops.Length)]; + if (rng.Next(2) == 0) { int b = rng.Next(NumU8); u8[k] = ApplyU8(u8[a], op, u8[b]); src.Append($" a{k} = a{a} {op} a{b}\n"); } + else { int c = rng.Next(0, 256); u8[k] = ApplyU8(u8[a], op, c); src.Append($" a{k} = a{a} {op} {c}\n"); } + } + else if (choice < 6) + { + int k = rng.Next(NumU8), a = rng.Next(NumU8), sh = rng.Next(0, 8); + if (rng.Next(2) == 0) { u8[k] = (u8[a] << sh) & 0xFF; src.Append($" a{k} = a{a} << {sh}\n"); } + else { u8[k] = (u8[a] >> sh) & 0xFF; src.Append($" a{k} = a{a} >> {sh}\n"); } + } + else if (choice < 8) + { + int k = rng.Next(NumU16), a = rng.Next(NumU16); + string op = ops[rng.Next(ops.Length)]; + if (rng.Next(2) == 0) { int b = rng.Next(NumU16); u16[k] = ApplyU16(u16[a], op, u16[b]); src.Append($" b{k} = b{a} {op} b{b}\n"); } + else { int c = rng.Next(0, 65536); u16[k] = ApplyU16(u16[a], op, c); src.Append($" b{k} = b{a} {op} {c}\n"); } + } + else + { + int k = rng.Next(NumU8), a = rng.Next(NumU8), j = rng.Next(NumHelpers); + u8[k] = Helper(j, u8[a]); + src.Append($" a{k} = h{j}(a{a})\n"); + } + } + + // ── Print every final value (one decimal per line) ───────────────── + var expected = new List(); + for (int i = 0; i < NumU8; i++) { src.Append($" print(a{i})\n"); expected.Add(u8[i]); } + for (int i = 0; i < NumU16; i++) { src.Append($" print(b{i})\n"); expected.Add(u16[i]); } + + src.Append(" while True:\n pass\n"); + return new AllocStressProgram(src.ToString(), input, expected); + } + + // PyMCU fixed-width semantics: the operation is performed at the operand width and the + // result wraps to that width (no C-style integer promotion). + private static int ApplyU8(int a, string op, int b) => op switch + { + "+" => (a + b) & 0xFF, + "-" => (a - b) & 0xFF, + "*" => (a * b) & 0xFF, + "&" => (a & b) & 0xFF, + "|" => (a | b) & 0xFF, + "^" => (a ^ b) & 0xFF, + _ => throw new ArgumentException(op), + }; + + private static int ApplyU16(int a, string op, int b) => op switch + { + "+" => (a + b) & 0xFFFF, + "-" => (a - b) & 0xFFFF, + "*" => (a * b) & 0xFFFF, + "&" => (a & b) & 0xFFFF, + "|" => (a | b) & 0xFFFF, + "^" => (a ^ b) & 0xFFFF, + _ => throw new ArgumentException(op), + }; +} diff --git a/tests/integration/IsrArrayGenerator.cs b/tests/integration/IsrArrayGenerator.cs new file mode 100644 index 00000000..78bbcf71 --- /dev/null +++ b/tests/integration/IsrArrayGenerator.cs @@ -0,0 +1,85 @@ +namespace PyMCU.IntegrationTests; + +/// +/// Interrupt-safety across the Z pointer (codegen backlog A36). main does runtime-indexed array +/// reads in a loop (which materialize the address into Z = R30:R31) while an ISR that ALSO does +/// a runtime-indexed array access fires. The ISR clobbers Z; the context-save must preserve it, +/// or main's in-progress array address is corrupted. This is the array/flash counterpart to the +/// uint16-MUL register-pair coverage — both exercise the caller-clobbered registers (R30/R31 +/// here) that the old fixed ISR save set omitted. main's printed values must equal the +/// ISR-free reference. +/// +public sealed class IsrArrayProgram +{ + public string Source { get; } + public byte InputByte { get; } + public IReadOnlyList Expected { get; } + + private IsrArrayProgram(string source, byte input, List expected) + => (Source, InputByte, Expected) = (source, input, expected); + + private const int ArrLen = 8; + private const int Reps = 40; + + public static IsrArrayProgram Generate(int seed) + { + var rng = new Random(seed); + byte input = (byte)(seed * 97 + 31); + var src = new System.Text.StringBuilder(); + + int s = input; + var arr = new int[ArrLen]; + for (int i = 0; i < ArrLen; i++) arr[i] = rng.Next(0, 256); + // ISR's own table (independent of main). + var gtbl = new int[4]; + for (int i = 0; i < 4; i++) gtbl[i] = rng.Next(0, 256); + int step = (rng.Next(0, 4) * 2) + 1; // odd → cycles through all 8 indices + + int acc = s, idx = 0, x = (s ^ 0x5A) & 0xFF; + + src.Append("from pymcu.types import uint8, interrupt, asm\n"); + src.Append("from pymcu.chips.atmega328p import TCCR2B, TIMSK2\n"); + src.Append("from pymcu.hal.uart import UART\n\n\n"); + src.Append($"gtbl: uint8[4] = [{gtbl[0]}, {gtbl[1]}, {gtbl[2]}, {gtbl[3]}]\n"); + src.Append("gidx: uint8 = 0\n"); + src.Append("gcnt: uint8 = 0\n\n\n"); + // ISR does a runtime-indexed table read → uses Z (R30:R31). + src.Append("@interrupt(0x0012)\n"); + src.Append("def t2_ovf():\n"); + src.Append(" global gidx, gcnt\n"); + src.Append(" gcnt = gtbl[gidx]\n"); + src.Append(" gidx = (gidx + 1) & 3\n\n\n"); + + src.Append("def main():\n"); + src.Append(" uart = UART(9600)\n"); + src.Append(" uart.println(\"GO\")\n"); + src.Append(" TCCR2B.value = 0x03\n TIMSK2.value = 0x01\n"); + src.Append(" asm(\"SEI\")\n"); + src.Append(" s: uint8 = uart.read_blocking()\n"); + src.Append(" arr: uint8[8] = [" + string.Join(", ", arr) + "]\n"); + src.Append(" acc: uint8 = s\n"); + src.Append(" idx: uint8 = 0\n"); + src.Append(" x: uint8 = s ^ 90\n"); + src.Append(" r: uint8 = 0\n"); + src.Append($" while r < {Reps}:\n"); + src.Append(" acc = acc + arr[idx]\n"); // runtime-indexed read → Z + src.Append($" idx = (idx + {step}) & 7\n"); + src.Append(" x = x * 3\n"); + src.Append(" acc = acc ^ x\n"); + src.Append(" r = r + 1\n"); + + for (int rep = 0; rep < Reps; rep++) + { + acc = (acc + arr[idx]) & 0xFF; + idx = (idx + step) & 7; + x = (x * 3) & 0xFF; + acc = (acc ^ x) & 0xFF; + } + + var expected = new List { acc, idx, x }; + src.Append(" print(acc)\n print(idx)\n print(x)\n"); + src.Append(" while True:\n pass\n"); + + return new IsrArrayProgram(src.ToString(), input, expected); + } +} diff --git a/tests/integration/IsrNestedCallGenerator.cs b/tests/integration/IsrNestedCallGenerator.cs new file mode 100644 index 00000000..22bccdac --- /dev/null +++ b/tests/integration/IsrNestedCallGenerator.cs @@ -0,0 +1,97 @@ +namespace PyMCU.IntegrationTests; + +/// +/// Interrupt-safety with a NESTED CALL inside the ISR (codegen backlog A34/A35). The ISR calls +/// a dedicated helper (entered in interrupt context) and keeps a local value live ACROSS that +/// call. This exercises two things the allocator must get right when an ISR can preempt main: +/// * the ISR's callee tree gets a stack region disjoint from main — the call-tree DFS that +/// allocates an ISR's region must follow into its callees, or the helper's slots alias +/// main's locals; +/// * the ISR's own call-spanning local must survive the nested call (saved/restored like any +/// callee-clobbered register), independently of main's live values. +/// main runs a register-pressure loop; its printed values must equal the ISR-free reference. +/// +public sealed class IsrNestedCallProgram +{ + public string Source { get; } + public byte InputByte { get; } + public IReadOnlyList Expected { get; } + + private IsrNestedCallProgram(string source, byte input, List expected) + => (Source, InputByte, Expected) = (source, input, expected); + + private const int NumU8 = 8; + private const int OpsPerIter = 12; + private const int Reps = 40; + + public static IsrNestedCallProgram Generate(int seed) + { + var rng = new Random(seed); + byte input = (byte)(seed * 71 + 23); + var src = new System.Text.StringBuilder(); + + int s = input; + var v = new int[NumU8]; + var initOp = new string[NumU8]; var initC = new int[NumU8]; + string[] ops = { "+", "-", "*", "&", "|", "^" }; + for (int i = 0; i < NumU8; i++) { initOp[i] = ops[rng.Next(ops.Length)]; initC[i] = rng.Next(0, 256); v[i] = ApplyU8(s, initOp[i], initC[i]); } + + var stmts = new List<(string Src, Action Apply)>(); + for (int n = 0; n < OpsPerIter; n++) + { + int k = rng.Next(NumU8), a = rng.Next(NumU8); + if (rng.Next(2) == 0) { int b = rng.Next(NumU8); string op = ops[rng.Next(ops.Length)]; stmts.Add(($"v{k} = v{a} {op} v{b}", () => v[k] = ApplyU8(v[a], op, v[b]))); } + else { int c = rng.Next(0, 256); string op = ops[rng.Next(ops.Length)]; stmts.Add(($"v{k} = v{a} {op} {c}", () => v[k] = ApplyU8(v[a], op, c))); } + } + + int ha = rng.Next(0, 256), hb = rng.Next(0, 256), ic = rng.Next(0, 256); + + src.Append("from pymcu.types import uint8, interrupt, asm\n"); + src.Append("from pymcu.chips.atmega328p import TCCR2B, TIMSK2\n"); + src.Append("from pymcu.hal.uart import UART\n\n\n"); + src.Append("acc: uint8 = 0\n\n\n"); + // Helper called ONLY from the ISR → runs in interrupt context. Its locals must be + // disjoint from main's frame. + src.Append("def isr_helper(x: uint8) -> uint8:\n"); + src.Append($" h0: uint8 = x + {ha}\n"); + src.Append($" h1: uint8 = h0 * 3\n"); + src.Append($" return h1 ^ {hb}\n\n\n"); + src.Append("@interrupt(0x0012)\n"); + src.Append("def t2_ovf():\n"); + src.Append(" global acc\n"); + src.Append($" t: uint8 = acc + {ic}\n"); // t is live across the nested call + src.Append(" acc = isr_helper(t) + t\n\n\n"); + + src.Append("def main():\n"); + src.Append(" uart = UART(9600)\n"); + src.Append(" uart.println(\"GO\")\n"); + src.Append(" TCCR2B.value = 0x01\n TIMSK2.value = 0x01\n"); + src.Append(" asm(\"SEI\")\n"); + src.Append(" s: uint8 = uart.read_blocking()\n"); + for (int i = 0; i < NumU8; i++) src.Append($" v{i}: uint8 = s {initOp[i]} {initC[i]}\n"); + src.Append(" r: uint8 = 0\n"); + src.Append($" while r < {Reps}:\n"); + foreach (var (line, _) in stmts) src.Append($" {line}\n"); + src.Append(" r = r + 1\n"); + + for (int rep = 0; rep < Reps; rep++) + foreach (var (_, apply) in stmts) apply(); + + var expected = new List(); + for (int i = 0; i < NumU8; i++) { src.Append($" print(v{i})\n"); expected.Add(v[i]); } + src.Append(" while True:\n pass\n"); + + return new IsrNestedCallProgram(src.ToString(), input, expected); + } + + private static int ApplyU8(int a, string op, int b) => op switch + { + "+" => (a + b) & 0xFF, + "-" => (a - b) & 0xFF, + "*" => (a * b) & 0xFF, + "&" => (a & b) & 0xFF, + "|" => (a | b) & 0xFF, + "^" => (a ^ b) & 0xFF, + _ => throw new ArgumentException(op), + }; +} diff --git a/tests/integration/IsrSafetyGenerator.cs b/tests/integration/IsrSafetyGenerator.cs new file mode 100644 index 00000000..85bb48f0 --- /dev/null +++ b/tests/integration/IsrSafetyGenerator.cs @@ -0,0 +1,106 @@ +namespace PyMCU.IntegrationTests; + +/// +/// Generator of interrupt-safety stress programs (codegen backlog A33). Each seed yields a +/// program where: +/// * a Timer2 overflow ISR fires very frequently (prescaler 1 → every 256 cycles) while +/// main runs, and does its own multi-step arithmetic on an ISR-owned global — using +/// registers, so it must save/restore everything it touches; +/// * main reads a runtime seed byte, derives a pool of uint8 locals, and repeatedly applies +/// a fixed sequence of arithmetic/bitwise statements in a loop (so the ISR interrupts it +/// mid-computation, with many values live), then prints the final values. +/// +/// The ISR's work is independent of main's locals, so a correct context save/restore leaves +/// main's printed values exactly equal to the ISR-free reference computed here. If the ISR +/// (now, or after the planned callee-save allocator change) fails to preserve a register that +/// main has live, a printed value diverges — exactly the property the redesign must keep. +/// +public sealed class IsrSafetyProgram +{ + public string Source { get; } + public byte InputByte { get; } + public IReadOnlyList Expected { get; } + + private IsrSafetyProgram(string source, byte input, List expected) + => (Source, InputByte, Expected) = (source, input, expected); + + private const int NumU8 = 8; + private const int OpsPerIter = 14; + private const int Reps = 40; // loop iterations: long enough for many ISR firings + + public static IsrSafetyProgram Generate(int seed) + { + var rng = new Random(seed); + byte input = (byte)(seed * 53 + 7); + var src = new System.Text.StringBuilder(); + + int s = input; + var v = new int[NumU8]; + var initOp = new string[NumU8]; var initC = new int[NumU8]; + string[] ops = { "+", "-", "*", "&", "|", "^" }; + for (int i = 0; i < NumU8; i++) { initOp[i] = ops[rng.Next(ops.Length)]; initC[i] = rng.Next(0, 256); v[i] = ApplyU8(s, initOp[i], initC[i]); } + + // Build the per-iteration statement list once (source line + reference action). + var stmts = new List<(string Src, Action Apply)>(); + for (int n = 0; n < OpsPerIter; n++) + { + int k = rng.Next(NumU8), a = rng.Next(NumU8); + int kind = rng.Next(0, 3); + if (kind == 0) { int b = rng.Next(NumU8); string op = ops[rng.Next(ops.Length)]; stmts.Add(($"v{k} = v{a} {op} v{b}", () => v[k] = ApplyU8(v[a], op, v[b]))); } + else if (kind == 1) { int c = rng.Next(0, 256); string op = ops[rng.Next(ops.Length)]; stmts.Add(($"v{k} = v{a} {op} {c}", () => v[k] = ApplyU8(v[a], op, c))); } + else { int sh = rng.Next(0, 8); bool left = rng.Next(2) == 0; stmts.Add(($"v{k} = v{a} {(left ? "<<" : ">>")} {sh}", () => v[k] = (left ? (v[a] << sh) : (v[a] >> sh)) & 0xFF)); } + } + + // ISR constants (its own state; independent of main's locals). + int ia = rng.Next(0, 256), ib = rng.Next(0, 256), ic = rng.Next(0, 256); + + // ── Source ───────────────────────────────────────────────────────── + src.Append("from pymcu.types import uint8, interrupt, asm\n"); + src.Append("from pymcu.chips.atmega328p import TCCR2B, TIMSK2\n"); + src.Append("from pymcu.hal.uart import UART\n\n\n"); + src.Append("acc: uint8 = 0\n\n\n"); + // Timer2 overflow vector = byte 0x0012. The ISR uses several locals so the codegen + // (and any future callee-save allocator) must preserve them around main's live values. + src.Append("@interrupt(0x0012)\n"); + src.Append("def t2_ovf():\n"); + src.Append(" global acc\n"); + src.Append($" w0: uint8 = acc + {ia}\n"); + src.Append($" w1: uint8 = w0 * 5\n"); + src.Append($" w2: uint8 = w1 ^ {ib}\n"); + src.Append($" acc = w2 - {ic}\n\n\n"); + + src.Append("def main():\n"); + src.Append(" uart = UART(9600)\n"); + src.Append(" uart.println(\"GO\")\n"); + src.Append(" TCCR2B.value = 0x01\n"); // Timer2 prescaler 1 → overflow every 256 cycles + src.Append(" TIMSK2.value = 0x01\n"); // TOIE2: enable overflow interrupt + src.Append(" asm(\"SEI\")\n"); + src.Append(" s: uint8 = uart.read_blocking()\n"); + for (int i = 0; i < NumU8; i++) src.Append($" v{i}: uint8 = s {initOp[i]} {initC[i]}\n"); + src.Append($" r: uint8 = 0\n"); + src.Append($" while r < {Reps}:\n"); + foreach (var (line, _) in stmts) src.Append($" {line}\n"); + src.Append(" r = r + 1\n"); + + // ── Reference: apply the loop body Reps times ────────────────────── + for (int rep = 0; rep < Reps; rep++) + foreach (var (_, apply) in stmts) apply(); + + var expected = new List(); + for (int i = 0; i < NumU8; i++) { src.Append($" print(v{i})\n"); expected.Add(v[i]); } + src.Append(" while True:\n pass\n"); + + return new IsrSafetyProgram(src.ToString(), input, expected); + } + + private static int ApplyU8(int a, string op, int b) => op switch + { + "+" => (a + b) & 0xFF, + "-" => (a - b) & 0xFF, + "*" => (a * b) & 0xFF, + "&" => (a & b) & 0xFF, + "|" => (a | b) & 0xFF, + "^" => (a ^ b) & 0xFF, + _ => throw new ArgumentException(op), + }; +} diff --git a/tests/integration/IsrU16Generator.cs b/tests/integration/IsrU16Generator.cs new file mode 100644 index 00000000..8130519c --- /dev/null +++ b/tests/integration/IsrU16Generator.cs @@ -0,0 +1,107 @@ +namespace PyMCU.IntegrationTests; + +/// +/// Interrupt-safety with 16-bit values (codegen backlog A35). main keeps a mix of uint8 AND +/// uint16 locals live across a register-pressure loop while a Timer2 overflow ISR — itself +/// doing uint16 arithmetic — fires repeatedly. This adds the register-PAIR dimension a +/// callee-save allocator must handle: the ISR must preserve any 16-bit register pair main +/// holds live, the ISR's own 16-bit pairs/slots must be disjoint, and 16-bit SRAM slots +/// (2 bytes) must not partially alias. main's printed values must equal the ISR-free reference. +/// +public sealed class IsrU16Program +{ + public string Source { get; } + public byte InputByte { get; } + public IReadOnlyList Expected { get; } + + private IsrU16Program(string source, byte input, List expected) + => (Source, InputByte, Expected) = (source, input, expected); + + private const int NumU8 = 6; + private const int NumU16 = 4; + private const int OpsPerIter = 14; + private const int Reps = 30; + + public static IsrU16Program Generate(int seed) + { + var rng = new Random(seed); + byte input = (byte)(seed * 83 + 29); + var src = new System.Text.StringBuilder(); + + int s = input; + var u8 = new int[NumU8]; var u16 = new int[NumU16]; + var u8Op = new string[NumU8]; var u8C = new int[NumU8]; + var u16Op = new string[NumU16]; var u16C = new int[NumU16]; + string[] ops = { "+", "-", "*", "&", "|", "^" }; + for (int i = 0; i < NumU8; i++) { u8Op[i] = ops[rng.Next(ops.Length)]; u8C[i] = rng.Next(0, 256); u8[i] = ApplyU8(s, u8Op[i], u8C[i]); } + for (int i = 0; i < NumU16; i++) { u16Op[i] = ops[rng.Next(ops.Length)]; u16C[i] = rng.Next(0, 65536); u16[i] = ApplyU16(s, u16Op[i], u16C[i]); } + + var stmts = new List<(string Src, Action Apply)>(); + for (int n = 0; n < OpsPerIter; n++) + { + if (rng.Next(2) == 0) + { + int k = rng.Next(NumU8), a = rng.Next(NumU8); string op = ops[rng.Next(ops.Length)]; + if (rng.Next(2) == 0) { int b = rng.Next(NumU8); stmts.Add(($"a{k} = a{a} {op} a{b}", () => u8[k] = ApplyU8(u8[a], op, u8[b]))); } + else { int c = rng.Next(0, 256); stmts.Add(($"a{k} = a{a} {op} {c}", () => u8[k] = ApplyU8(u8[a], op, c))); } + } + else + { + int k = rng.Next(NumU16), a = rng.Next(NumU16); string op = ops[rng.Next(ops.Length)]; + if (rng.Next(2) == 0) { int b = rng.Next(NumU16); stmts.Add(($"b{k} = b{a} {op} b{b}", () => u16[k] = ApplyU16(u16[a], op, u16[b]))); } + else { int c = rng.Next(0, 65536); stmts.Add(($"b{k} = b{a} {op} {c}", () => u16[k] = ApplyU16(u16[a], op, c))); } + } + } + + int ia = rng.Next(0, 65536), ib = rng.Next(0, 65536); + + src.Append("from pymcu.types import uint8, uint16, interrupt, asm\n"); + src.Append("from pymcu.chips.atmega328p import TCCR2B, TIMSK2\n"); + src.Append("from pymcu.hal.uart import UART\n\n\n"); + src.Append("acc16: uint16 = 0\n\n\n"); + // The ISR does 16-bit arithmetic → register pairs + ADD/ADC, EOR byte-wise, etc. + src.Append("@interrupt(0x0012)\n"); + src.Append("def t2_ovf():\n"); + src.Append(" global acc16\n"); + src.Append($" w0: uint16 = acc16 + {ia}\n"); + src.Append($" w1: uint16 = w0 ^ {ib}\n"); + src.Append(" acc16 = w1 * 3\n\n\n"); + + src.Append("def main():\n"); + src.Append(" uart = UART(9600)\n"); + src.Append(" uart.println(\"GO\")\n"); + src.Append(" TCCR2B.value = 0x03\n TIMSK2.value = 0x01\n"); + src.Append(" asm(\"SEI\")\n"); + src.Append(" s: uint8 = uart.read_blocking()\n"); + for (int i = 0; i < NumU8; i++) src.Append($" a{i}: uint8 = s {u8Op[i]} {u8C[i]}\n"); + for (int i = 0; i < NumU16; i++) src.Append($" b{i}: uint16 = uint16(s) {u16Op[i]} {u16C[i]}\n"); + src.Append(" r: uint8 = 0\n"); + src.Append($" while r < {Reps}:\n"); + foreach (var (line, _) in stmts) src.Append($" {line}\n"); + src.Append(" r = r + 1\n"); + + for (int rep = 0; rep < Reps; rep++) + foreach (var (_, apply) in stmts) apply(); + + var expected = new List(); + for (int i = 0; i < NumU8; i++) { src.Append($" print(a{i})\n"); expected.Add(u8[i]); } + for (int i = 0; i < NumU16; i++) { src.Append($" print(b{i})\n"); expected.Add(u16[i]); } + src.Append(" while True:\n pass\n"); + + return new IsrU16Program(src.ToString(), input, expected); + } + + private static int ApplyU8(int a, string op, int b) => op switch + { + "+" => (a + b) & 0xFF, "-" => (a - b) & 0xFF, "*" => (a * b) & 0xFF, + "&" => (a & b) & 0xFF, "|" => (a | b) & 0xFF, "^" => (a ^ b) & 0xFF, + _ => throw new ArgumentException(op), + }; + + private static int ApplyU16(int a, string op, int b) => op switch + { + "+" => (a + b) & 0xFFFF, "-" => (a - b) & 0xFFFF, "*" => (a * b) & 0xFFFF, + "&" => (a & b) & 0xFFFF, "|" => (a | b) & 0xFFFF, "^" => (a ^ b) & 0xFFFF, + _ => throw new ArgumentException(op), + }; +} diff --git a/tests/integration/PymcuCompiler.cs b/tests/integration/PymcuCompiler.cs index 890c41ee..e3c1bf79 100644 --- a/tests/integration/PymcuCompiler.cs +++ b/tests/integration/PymcuCompiler.cs @@ -44,6 +44,47 @@ public static string BuildFixture(string name) => Cache.GetOrAdd("fx:" + name, _ => new Lazy(() => Compile(Path.Combine(RepoRoot, "tests", "integration", "fixtures", name), name))).Value; + /// + /// Absolute path of a fixture directory — for tests that inspect build + /// artifacts (e.g. dist/debug/firmware.asm) after . + /// + public static string FixtureDir(string name) + => Path.Combine(RepoRoot, "tests", "integration", "fixtures", name); + + /// + /// Compiles an arbitrary generated main.py source (e.g. a property/differential + /// test program for the register allocator). The program is materialized into a + /// throwaway project under the system temp directory and built with pymcu build. + /// Cached by content hash so identical programs compile once. + /// + public static string BuildSource(string mainPy) + => Cache.GetOrAdd("src:" + Sha(mainPy), _ => new Lazy(() => CompileSource(mainPy))).Value; + + private static string Sha(string s) + { + var bytes = System.Security.Cryptography.SHA1.HashData(System.Text.Encoding.UTF8.GetBytes(s)); + return Convert.ToHexString(bytes); + } + + private static string CompileSource(string mainPy) + { + var dir = Path.Combine(Path.GetTempPath(), "pymcu-gen", Sha(mainPy)[..16]); + Directory.CreateDirectory(Path.Combine(dir, "src")); + File.WriteAllText(Path.Combine(dir, "pyproject.toml"), + "[project]\n" + + "name = \"gen\"\n" + + "version = \"0.1.0\"\n" + + "requires-python = \">=3.11\"\n" + + "dependencies = [\"pymcu-stdlib>=0.1.2a5\", \"pymcu>=0.1.0a27\"]\n\n" + + "[tool.pymcu]\n" + + "target = \"atmega328p\"\n" + + "frequency = 16000000\n" + + "sources = \"src\"\n" + + "entry = \"main.py\"\n"); + File.WriteAllText(Path.Combine(dir, "src", "main.py"), mainPy); + return Compile(dir, "gen-" + Sha(mainPy)[..8]); + } + // ── Internal ───────────────────────────────────────────────────────────── private static string Compile(string exampleDir, string name) diff --git a/tests/integration/SignedDivModGenerator.cs b/tests/integration/SignedDivModGenerator.cs new file mode 100644 index 00000000..3b9188ee --- /dev/null +++ b/tests/integration/SignedDivModGenerator.cs @@ -0,0 +1,110 @@ +namespace PyMCU.IntegrationTests; + +/// +/// Generates a program that exercises the signed floor div/mod runtime routines +/// (__divs8/__mods8, __divs16/__mods16, __divs32/__mods32 — codegen backlog A38) over a +/// fixed list of (a, b) pairs and computes the same results with Python's floor semantics +/// as the oracle. +/// +/// Operands are materialized at runtime: a seed byte of 0 is read with read_blocking() into +/// `base` (compiler-opaque), and each operand is `base + constant`. Because `base` is a runtime +/// read the division cannot constant-fold, so the actual signed routine runs. Each quotient/ +/// remainder is stored into a typed local (forcing the division at the operand width, e.g. an +/// int8 // int8 stays 8-bit) and printed. The oracle wraps results to the type width so the +/// overflow edges (e.g. INT_MIN // -1) match the firmware's fixed-width wrap. +/// +public sealed class SignedDivModProgram +{ + public string Source { get; } + public byte InputByte => 0; // base = int8(0) = 0 + public IReadOnlyList Expected { get; } + + private SignedDivModProgram(string source, List expected) => (Source, Expected) = (source, expected); + + // Python floor division/modulo, then wrapped to the signed type width (matches the + // firmware: the quotient/remainder are stored in a fixed-width signed local). + private static (long q, long r) FloorDivMod(long a, long b, int bytes) + { + long q = a / b; // C#: truncates toward zero + long r = a - q * b; + if (r != 0 && (r < 0) != (b < 0)) { q -= 1; r += b; } + return (Wrap(q, bytes), Wrap(r, bytes)); + } + + private static long Wrap(long v, int bytes) => bytes switch + { + 1 => (sbyte)v, + 2 => (short)v, + _ => (int)v, + }; + + // The frontend lexer rejects the literal 2147483648 (|int32.MinValue|), so emit int32 + // MinValue as an expression whose sub-literals are each within range. + private static string Lit(long v) => v == int.MinValue ? "(-2147483647 - 1)" : v.ToString(); + + // A curated, compact set of (a, b) pairs: every sign quadrant at inexact / exact / + // equal / |a|<|b| magnitudes, zero dividend, ±1 divisors, and the width's min/max edges + // (including the INT_MIN // -1 overflow). Kept small so even the int32 program — where + // each pair pulls in the large 32-bit routine — stays within the 32 KB flash of the Uno. + private static IEnumerable<(long a, long b)> Pairs(long min, long max) => new (long, long)[] + { + (7, 3), (7, -3), (-7, 3), (-7, -3), // inexact, all four sign quadrants + (8, 2), (8, -2), (-8, 2), (-8, -2), // exact division + (7, 7), (-7, 7), (7, -7), (-7, -7), // |a| == |b| + (2, 7), (-2, 7), (2, -7), (-2, -7), // |a| < |b| (quotient 0 or -1) + (0, 3), (0, -3), // zero dividend + (100, 1), (-100, 1), (100, -1), (-100, -1), // ±1 divisors + (max, 3), (min, 3), (max, -3), (min, -3), // magnitude edges + (max, -1), (min, -1), // INT_MAX // -1, INT_MIN // -1 (overflow wrap) + (min, min), (max, max), (min, max), (max, min), + }; + + public static SignedDivModProgram Generate(string typeName, int bytes) + { + long min = bytes switch { 1 => sbyte.MinValue, 2 => short.MinValue, _ => int.MinValue }; + long max = bytes switch { 1 => sbyte.MaxValue, 2 => short.MaxValue, _ => int.MaxValue }; + + var pairs = Pairs(min, max).ToList(); + var expected = new List(); + var src = new System.Text.StringBuilder(); + + src.Append($"from pymcu.types import {typeName}, uint8\n"); + src.Append("from pymcu.hal.uart import UART\n\n\n"); + src.Append("def main():\n"); + src.Append(" uart = UART(9600)\n"); + src.Append(" uart.println(\"GO\")\n"); + src.Append(" s: uint8 = uart.read_blocking()\n"); + src.Append($" base: {typeName} = {typeName}(s)\n"); // = 0 at runtime, opaque to the compiler + + int n = 0; + foreach (var (a, b) in pairs) + { + // base + a / base + b -> a / b at runtime (base == 0), but not constant-folded. + src.Append($" a{n}: {typeName} = base + {Lit(a)}\n"); + src.Append($" b{n}: {typeName} = base + {Lit(b)}\n"); + src.Append($" q{n}: {typeName} = a{n} // b{n}\n"); + src.Append($" r{n}: {typeName} = a{n} % b{n}\n"); + // Print each result as its raw little-endian bytes rather than the value, so the + // check validates the exact 32-bit pattern independent of the signed-print path + // (print(int32) still truncates to 16 bits — A37). Each byte is 0..255, which the + // unsigned formatter prints verbatim. Shift amounts (0,8,16,24) stay below the width. + var (q, r) = FloorDivMod(a, b, bytes); + for (int k = 0; k < bytes; k++) + { + // uint8(...) reinterprets the selected byte as unsigned (0..255), avoiding + // any ambiguity about the masked expression's type. + src.Append($" print(uint8(q{n} >> {8 * k}))\n"); + expected.Add((q >> (8 * k)) & 0xFF); + } + for (int k = 0; k < bytes; k++) + { + src.Append($" print(uint8(r{n} >> {8 * k}))\n"); + expected.Add((r >> (8 * k)) & 0xFF); + } + n++; + } + + src.Append(" while True:\n pass\n"); + return new SignedDivModProgram(src.ToString(), expected); + } +} diff --git a/tests/integration/SignedStressGenerator.cs b/tests/integration/SignedStressGenerator.cs new file mode 100644 index 00000000..d80122ca --- /dev/null +++ b/tests/integration/SignedStressGenerator.cs @@ -0,0 +1,143 @@ +namespace PyMCU.IntegrationTests; + +/// +/// Signed counterpart of : deterministic property/differential +/// programs over a pool of int8/int16 locals, exercising the full signed operator set +/// (+ - * & | ^, floor // and %, arithmetic >> and <<, plus a call-spanning signed helper). +/// Each seed's program is also evaluated in C# with PyMCU's fixed-width *signed* semantics +/// (two's-complement wrap to the operand width; // and % follow Python's floor/divisor-signed +/// rules) to form the oracle. A mismatch is a signed-codegen or allocator bug. +/// +/// This is the systematic fidelity check for signed arithmetic — the area that historically +/// fell back to unsigned routines (codegen backlog A37/A38). Values come from a runtime seed +/// byte so nothing constant-folds; // and % always use a non-zero constant divisor so no +/// runtime division-by-zero can occur. +/// +public sealed class SignedStressProgram +{ + public string Source { get; } + public byte InputByte { get; } + public IReadOnlyList Expected { get; } + + private SignedStressProgram(string source, byte input, List expected) + => (Source, InputByte, Expected) = (source, input, expected); + + private const int NumI8 = 10; + private const int NumI16 = 5; + private const int NumStmts = 46; + + // Non-zero divisors (both signs) for // and %. + private static readonly int[] Div8 = { 1, -1, 2, -2, 3, -3, 7, -7, 10, -10, 5, -5 }; + private static readonly int[] Div16 = { 1, -1, 2, -2, 3, -3, 7, -7, 100, -100, 1000, -1000 }; + + public static SignedStressProgram Generate(int seed) + { + var rng = new Random(seed); + byte input = (byte)(seed * 53 + 17); + int s = input; + int s8 = (sbyte)input; // seed reinterpreted as signed int8 + + var i8 = new int[NumI8]; + var i16 = new int[NumI16]; + var src = new System.Text.StringBuilder(); + + string[] initOps = { "+", "-", "*", "^", "&", "|" }; + var i8Op = new string[NumI8]; var i8C = new int[NumI8]; + var i16Op = new string[NumI16]; var i16C = new int[NumI16]; + for (int i = 0; i < NumI8; i++) { i8Op[i] = initOps[rng.Next(initOps.Length)]; i8C[i] = rng.Next(-128, 128); i8[i] = ApplyI8(s8, i8Op[i], i8C[i]); } + for (int i = 0; i < NumI16; i++) { i16Op[i] = initOps[rng.Next(initOps.Length)]; i16C[i] = rng.Next(-32768, 32768); i16[i] = ApplyI16((short)input, i16Op[i], i16C[i]); } + + // A call-spanning signed helper with two return paths and a floor-divide inside, so the + // signed call path is exercised under register pressure. + int hC = rng.Next(-128, 128); int hD = Div8[rng.Next(Div8.Length)]; + int Helper(int x) => (x < 0) ? ApplyI8(x, "//", hD) : ApplyI8(ApplyI8(x, "*", 3), "-", hC); + + src.Append("from pymcu.types import int8, int16, uint8\n"); + src.Append("from pymcu.hal.uart import UART\n\n\n"); + src.Append("def hlp(x: int8) -> int8:\n"); + src.Append($" if x < 0:\n return x // {hD}\n return x * 3 - {hC}\n\n\n"); + + src.Append("def main():\n"); + src.Append(" uart = UART(9600)\n"); + src.Append(" uart.println(\"GO\")\n"); + src.Append(" s: uint8 = uart.read_blocking()\n"); + for (int i = 0; i < NumI8; i++) src.Append($" a{i}: int8 = int8(s) {i8Op[i]} {i8C[i]}\n"); + for (int i = 0; i < NumI16; i++) src.Append($" b{i}: int16 = int16(s) {i16Op[i]} {i16C[i]}\n"); + + string[] ops = { "+", "-", "*", "&", "|", "^" }; + for (int stmt = 0; stmt < NumStmts; stmt++) + { + int choice = rng.Next(0, 12); + if (choice < 3) // int8 arithmetic/bitwise + { + int k = rng.Next(NumI8), a = rng.Next(NumI8); string op = ops[rng.Next(ops.Length)]; + if (rng.Next(2) == 0) { int b = rng.Next(NumI8); i8[k] = ApplyI8(i8[a], op, i8[b]); src.Append($" a{k} = a{a} {op} a{b}\n"); } + else { int c = rng.Next(-128, 128); i8[k] = ApplyI8(i8[a], op, c); src.Append($" a{k} = a{a} {op} {c}\n"); } + } + else if (choice < 5) // int8 floor // and % (constant non-zero divisor) + { + int k = rng.Next(NumI8), a = rng.Next(NumI8), d = Div8[rng.Next(Div8.Length)]; + string op = rng.Next(2) == 0 ? "//" : "%"; + i8[k] = ApplyI8(i8[a], op, d); src.Append($" a{k} = a{a} {op} {d}\n"); + } + else if (choice < 7) // int8 shifts + { + int k = rng.Next(NumI8), a = rng.Next(NumI8), sh = rng.Next(0, 8); + string op = rng.Next(2) == 0 ? "<<" : ">>"; + i8[k] = ApplyI8(i8[a], op, sh); src.Append($" a{k} = a{a} {op} {sh}\n"); + } + else if (choice < 9) // int16 arithmetic/bitwise + { + int k = rng.Next(NumI16), a = rng.Next(NumI16); string op = ops[rng.Next(ops.Length)]; + if (rng.Next(2) == 0) { int b = rng.Next(NumI16); i16[k] = ApplyI16(i16[a], op, i16[b]); src.Append($" b{k} = b{a} {op} b{b}\n"); } + else { int c = rng.Next(-32768, 32768); i16[k] = ApplyI16(i16[a], op, c); src.Append($" b{k} = b{a} {op} {c}\n"); } + } + else if (choice < 11) // int16 floor // / % / shift + { + int k = rng.Next(NumI16), a = rng.Next(NumI16); + int pick = rng.Next(3); + if (pick == 0) { int d = Div16[rng.Next(Div16.Length)]; i16[k] = ApplyI16(i16[a], "//", d); src.Append($" b{k} = b{a} // {d}\n"); } + else if (pick == 1) { int d = Div16[rng.Next(Div16.Length)]; i16[k] = ApplyI16(i16[a], "%", d); src.Append($" b{k} = b{a} % {d}\n"); } + else { int sh = rng.Next(0, 16); string op = rng.Next(2) == 0 ? "<<" : ">>"; i16[k] = ApplyI16(i16[a], op, sh); src.Append($" b{k} = b{a} {op} {sh}\n"); } + } + else // call-spanning signed helper + { + int k = rng.Next(NumI8), a = rng.Next(NumI8); + i8[k] = Helper(i8[a]); src.Append($" a{k} = hlp(a{a})\n"); + } + } + + var expected = new List(); + for (int i = 0; i < NumI8; i++) { src.Append($" print(a{i})\n"); expected.Add(i8[i]); } + for (int i = 0; i < NumI16; i++) { src.Append($" print(b{i})\n"); expected.Add(i16[i]); } + src.Append(" while True:\n pass\n"); + + return new SignedStressProgram(src.ToString(), input, expected); + } + + private static (int q, int r) FloorDivMod(int a, int b) + { + int q = a / b, r = a - q * b; + if (r != 0 && (r < 0) != (b < 0)) { q -= 1; r += b; } + return (q, r); + } + + // PyMCU fixed-width signed semantics: operate at the operand width, wrap (two's complement). + private static int ApplyI8(int a, string op, int b) => (sbyte)(op switch + { + "+" => a + b, "-" => a - b, "*" => a * b, + "&" => a & b, "|" => a | b, "^" => a ^ b, + "<<" => a << b, ">>" => a >> b, // C# >> on int is arithmetic (matches ASR) + "//" => FloorDivMod(a, b).q, "%" => FloorDivMod(a, b).r, + _ => throw new ArgumentException(op), + }); + + private static int ApplyI16(int a, string op, int b) => (short)(op switch + { + "+" => a + b, "-" => a - b, "*" => a * b, + "&" => a & b, "|" => a | b, "^" => a ^ b, + "<<" => a << b, ">>" => a >> b, + "//" => FloorDivMod(a, b).q, "%" => FloorDivMod(a, b).r, + _ => throw new ArgumentException(op), + }); +} diff --git a/tests/integration/Tests/AVR/AllocatorStressTests.cs b/tests/integration/Tests/AVR/AllocatorStressTests.cs new file mode 100644 index 00000000..c34f29ce --- /dev/null +++ b/tests/integration/Tests/AVR/AllocatorStressTests.cs @@ -0,0 +1,228 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Property / differential tests for the AVR register allocator and codegen. +/// +/// Each seed (see ) generates a register-pressure-heavy +/// program whose final variable values are also computed in C# with PyMCU's exact +/// fixed-width semantics. The program is compiled, simulated, and its printed decimals are +/// compared against that oracle. A clobbered/mis-homed register, a dropped call-spanning +/// value, or any allocation bug shows up as a mismatch — the safety net for the planned +/// register-allocator redesign (codegen backlog A17/A31): run this before and after, the +/// values must stay identical. +/// +[TestFixture] +public class AllocatorStressTests +{ + // A spread of fixed seeds: deterministic and reproducible. Each is a distinct program; + // builds are content-hash cached. Keep the count modest — each compiles + simulates. + private static readonly int[] Seeds = { 1, 2, 3, 7, 13, 42, 99, 123, 777, 2024 }; + + [TestCaseSource(nameof(Seeds))] + public void GeneratedProgram_MatchesReferenceSemantics(int seed) + { + var prog = AllocStressProgram.Generate(seed); + var hex = PymcuCompiler.BuildSource(prog.Source); + + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + + // Run to the "GO" banner, then inject the runtime seed byte the program reads with + // read_blocking(). Every value derives from it, so nothing constant-folds. + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(prog.InputByte); + + // "GO\n" banner + one decimal per printed value, each "\n". + int wantLines = prog.Expected.Count + 1; + uno.RunUntilSerial(uno.Serial, s => CountNewlines(s) >= wantLines, maxMs: 4000); + + var got = ParseDecimalsAfterBanner(uno.Serial.Text, prog.Expected.Count); + + got.Should().Equal(prog.Expected, + $"seed {seed}: simulated output must match the fixed-width reference.\n" + + $"--- program ---\n{prog.Source}\n--- serial ---\n{uno.Serial.Text}"); + } + + // Interrupt-safety: a Timer2 overflow ISR fires repeatedly while main runs a + // register-pressure computation. A correct context save/restore leaves main's printed + // values equal to the ISR-free reference. This is the path the callee-save allocator + // redesign (A33) must not break — an ISR that fails to preserve a register main has live + // would diverge here. + [TestCaseSource(nameof(Seeds))] + public void IsrDoesNotPerturbMain(int seed) + { + var prog = IsrSafetyProgram.Generate(seed); + var hex = PymcuCompiler.BuildSource(prog.Source); + + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(prog.InputByte); + uno.RunUntilSerial(uno.Serial, s => CountNewlines(s) >= prog.Expected.Count + 1, maxMs: 6000); + + var got = ParseDecimalsAfterBanner(uno.Serial.Text, prog.Expected.Count); + got.Should().Equal(prog.Expected, + $"seed {seed}: a Timer2 ISR firing mid-computation must not perturb main's values.\n" + + $"--- program ---\n{prog.Source}\n--- serial ---\n{uno.Serial.Text}"); + } + + // Heavy sweep for validating the register-allocator redesign: run before and after the + // change, the results must be identical. [Explicit] so it does not slow the normal CI run + // (each case compiles + simulates). Invoke with: + // dotnet test --filter "FullyQualifiedName~AllocatorStressTests.Sweep" + [Test, Explicit("heavy: run on demand to validate an allocator/codegen change")] + public void Sweep() + { + var failures = new List(); + for (int seed = 1; seed <= 100; seed++) + { + var prog = AllocStressProgram.Generate(seed); + var hex = PymcuCompiler.BuildSource(prog.Source); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(prog.InputByte); + uno.RunUntilSerial(uno.Serial, s => CountNewlines(s) >= prog.Expected.Count + 1, maxMs: 4000); + var got = ParseDecimalsAfterBanner(uno.Serial.Text, prog.Expected.Count); + if (!got.SequenceEqual(prog.Expected)) + failures.Add($"seed {seed}: expected [{string.Join(",", prog.Expected)}] got [{string.Join(",", got)}]"); + } + failures.Should().BeEmpty($"{failures.Count} seed(s) miscompiled:\n{string.Join("\n", failures)}"); + } + + // Heavy interrupt-safety sweep (100 seeds). [Explicit] like Sweep. + [Test, Explicit("heavy: run on demand to validate an ISR/allocator change")] + public void IsrSweep() + { + var failures = new List(); + for (int seed = 1; seed <= 100; seed++) + { + var prog = IsrSafetyProgram.Generate(seed); + var hex = PymcuCompiler.BuildSource(prog.Source); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(prog.InputByte); + uno.RunUntilSerial(uno.Serial, s => CountNewlines(s) >= prog.Expected.Count + 1, maxMs: 6000); + var got = ParseDecimalsAfterBanner(uno.Serial.Text, prog.Expected.Count); + if (!got.SequenceEqual(prog.Expected)) + failures.Add($"seed {seed}: expected [{string.Join(",", prog.Expected)}] got [{string.Join(",", got)}]"); + } + failures.Should().BeEmpty($"{failures.Count} seed(s) had an ISR perturb main:\n{string.Join("\n", failures)}"); + } + + // Z-pointer interrupt safety: main does runtime-indexed array reads (address materialized + // into Z = R30:R31) while an ISR that also indexes an array fires. The ISR clobbers Z; the + // context-save must preserve it or main's in-progress array address is corrupted. Exercises + // the R30/R31 part of the caller-clobbered save set. + [TestCaseSource(nameof(Seeds))] + public void IsrArrayDoesNotPerturbMain(int seed) + { + var prog = IsrArrayProgram.Generate(seed); + var hex = PymcuCompiler.BuildSource(prog.Source); + + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(prog.InputByte); + uno.RunUntilSerial(uno.Serial, s => CountNewlines(s) >= prog.Expected.Count + 1, maxMs: 6000); + + var got = ParseDecimalsAfterBanner(uno.Serial.Text, prog.Expected.Count); + got.Should().Equal(prog.Expected, + $"seed {seed}: an array-indexing ISR (Z pointer) must not perturb main's values.\n" + + $"--- program ---\n{prog.Source}\n--- serial ---\n{uno.Serial.Text}"); + } + + // 16-bit interrupt safety: main holds a mix of uint8 and uint16 locals live across a loop + // while an ISR doing uint16 arithmetic fires. Adds the register-PAIR dimension — the ISR + // must preserve any 16-bit pair main holds, and 16-bit slots must not partially alias. + [TestCaseSource(nameof(Seeds))] + public void IsrU16DoesNotPerturbMain(int seed) + { + var prog = IsrU16Program.Generate(seed); + var hex = PymcuCompiler.BuildSource(prog.Source); + + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(prog.InputByte); + uno.RunUntilSerial(uno.Serial, s => CountNewlines(s) >= prog.Expected.Count + 1, maxMs: 6000); + + var got = ParseDecimalsAfterBanner(uno.Serial.Text, prog.Expected.Count); + got.Should().Equal(prog.Expected, + $"seed {seed}: a uint16 ISR must not perturb main's 8/16-bit values.\n" + + $"--- program ---\n{prog.Source}\n--- serial ---\n{uno.Serial.Text}"); + } + + // An ISR that makes a nested call (to a helper entered in interrupt context) while keeping + // a local live across that call. Validates that the ISR's whole call-tree gets a stack + // region disjoint from main, and that the ISR's call-spanning local survives the nested + // call — both independent of main's values, which must equal the ISR-free reference. + [TestCaseSource(nameof(Seeds))] + public void IsrWithNestedCallDoesNotPerturbMain(int seed) + { + var prog = IsrNestedCallProgram.Generate(seed); + var hex = PymcuCompiler.BuildSource(prog.Source); + + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(prog.InputByte); + uno.RunUntilSerial(uno.Serial, s => CountNewlines(s) >= prog.Expected.Count + 1, maxMs: 6000); + + var got = ParseDecimalsAfterBanner(uno.Serial.Text, prog.Expected.Count); + got.Should().Equal(prog.Expected, + $"seed {seed}: an ISR making a nested call must not perturb main's values.\n" + + $"--- program ---\n{prog.Source}\n--- serial ---\n{uno.Serial.Text}"); + } + + // Two simultaneous interrupt handlers (Timer0 + Timer2 overflow), each with its own + // locals, fire while main runs a register-pressure loop. Validates that the allocator + // gives each ISR a region disjoint from main AND from the other ISR (the per-ISR isrBase + // increment in StackAllocator) — an overlap between the two ISRs, or either with main, + // would perturb main's printed values. + [TestCaseSource(nameof(Seeds))] + public void TwoIsrsDoNotPerturbMain(int seed) + { + var prog = TwoIsrProgram.Generate(seed); + var hex = PymcuCompiler.BuildSource(prog.Source); + + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(prog.InputByte); + uno.RunUntilSerial(uno.Serial, s => CountNewlines(s) >= prog.Expected.Count + 1, maxMs: 6000); + + var got = ParseDecimalsAfterBanner(uno.Serial.Text, prog.Expected.Count); + got.Should().Equal(prog.Expected, + $"seed {seed}: two ISRs firing mid-computation must not perturb main's values.\n" + + $"--- program ---\n{prog.Source}\n--- serial ---\n{uno.Serial.Text}"); + } + + private static int CountNewlines(string s) + { + int n = 0; + foreach (var c in s) if (c == '\n') n++; + return n; + } + + // Skips the "GO" banner, then reads the next `count` lines as decimals. + private static List ParseDecimalsAfterBanner(string text, int count) + { + var lines = text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var result = new List(); + for (int i = start + 1; i < lines.Length && result.Count < count; i++) + { + var t = lines[i].Trim(); + if (t.Length == 0) continue; + if (int.TryParse(t, out int v)) result.Add(v); + else break; // unexpected garbage — stop; the Equal assertion will report it + } + return result; + } +} diff --git a/tests/integration/Tests/AVR/AnalogInOutMpTests.cs b/tests/integration/Tests/AVR/AnalogInOutMpTests.cs new file mode 100644 index 00000000..9662e66d --- /dev/null +++ b/tests/integration/Tests/AVR/AnalogInOutMpTests.cs @@ -0,0 +1,44 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; +using Avr8Sharp.TestKit; +using AVR8Sharp.Core.Peripherals; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration tests for examples/analog-inout-mp — Arduino "AnalogInOutSerial" +/// on the MicroPython compatibility layer (machine.ADC / PWM / UART). +/// +/// Constructing ADC(Pin(14)) (int overload) before PWM(Pin("PD6")) (str overload) +/// used to leak the int pin id into the PWM's pin name, mis-resolving the timer +/// and emitting a stray RET. With A0 held at ADC count 512 the duty is +/// 512 >> 2 == 128, so OCR0A == 128 -- proving both Pin overloads resolve +/// independently and the ADC -> PWM chain works on the machine layer. +/// +[TestFixture] +public class AnalogInOutMpTests +{ + private const int OCR0A = 0x47; + private static string _hex = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _hex = PymcuCompiler.Build("analog-inout-mp"); + + private static ArduinoUnoSimulation SimWithA0(double adcCount) + { + var uno = new ArduinoUnoSimulation(); + uno.WithHex(_hex); + uno.AddAdc(AvrAdc.AdcConfig, out var adc); + adc.ChannelValues[0] = adcCount / 1024.0 * 5.0; // A0 == channel 0 (PC0) + return uno; + } + + [Test] + public void Pwm_DutyTracksAnalogInput() + { + var uno = SimWithA0(512); + uno.RunUntilSerialBytes(uno.Serial, 20, maxMs: 4000); + uno.Data[OCR0A].Should().Be(128, "analogWrite duty = sensor (512) >> 2 = 128"); + } +} diff --git a/tests/integration/Tests/AVR/AnalogInOutTests.cs b/tests/integration/Tests/AVR/AnalogInOutTests.cs new file mode 100644 index 00000000..fec991f5 --- /dev/null +++ b/tests/integration/Tests/AVR/AnalogInOutTests.cs @@ -0,0 +1,40 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; +using Avr8Sharp.TestKit; +using AVR8Sharp.Core.Peripherals; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration tests for examples/analog-inout — Arduino "AnalogInOutSerial" +/// on the native PyMCU HAL (UART + AnalogPin + PWM). Reads A0, maps 0-1023 to a +/// 0-255 PWM duty on D6 (OC0A), and echoes the duty byte. With A0 at ADC count +/// 512 the duty is 512 >> 2 == 128 -> OCR0A == 128. +/// +[TestFixture] +public class AnalogInOutTests +{ + private const int OCR0A = 0x47; + private static string _hex = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _hex = PymcuCompiler.Build("analog-inout"); + + private static ArduinoUnoSimulation SimWithA0(double adcCount) + { + var uno = new ArduinoUnoSimulation(); + uno.WithHex(_hex); + uno.AddAdc(AvrAdc.AdcConfig, out var adc); + adc.ChannelValues[0] = adcCount / 1024.0 * 5.0; // A0 == channel 0 (PC0) + return uno; + } + + [Test] + public void Pwm_DutyTracksAnalogInput() + { + var uno = SimWithA0(512); + uno.RunUntilSerialBytes(uno.Serial, 20, maxMs: 4000); + uno.Data[OCR0A].Should().Be(128, "analogWrite duty = sensor (512) >> 2 = 128"); + } +} diff --git a/tests/integration/Tests/AVR/CompatCpMicrocontrollerTests.cs b/tests/integration/Tests/AVR/CompatCpMicrocontrollerTests.cs index 4aa96493..29886774 100644 --- a/tests/integration/Tests/AVR/CompatCpMicrocontrollerTests.cs +++ b/tests/integration/Tests/AVR/CompatCpMicrocontrollerTests.cs @@ -15,8 +15,9 @@ namespace PyMCU.IntegrationTests.Tests.AVR; /// Expected UART: [0x5A, 0x00, 0x44]. /// Byte 0 (0x5A): a value written to and read back from nvm[0] -- proves an /// EEPROM write/read round-trip on hardware. -/// Byte 1 (0x00): cpu.reset_reason == ResetReason.POWER_ON -- the simulator -/// boots with MCUSR.PORF set, decoded by the MCUSR reader. +/// Byte 1 (0x00): cpu.reset_reason == ResetReason.POWER_ON -- the test seeds +/// MCUSR.PORF (real silicon sets it on power-up; the bare core +/// does not), and the @property getter decodes it live. /// Byte 2 (0x44, 'D'): the done marker, reached only after the watchdog /// arm/feed/disable sequence ran without resetting the chip. /// @@ -30,6 +31,13 @@ public class CompatCpMicrocontrollerTests { private const byte ResetReasonPowerOn = 0x00; + // MCUSR (DATA 0x54) and its power-on flag (PORF, bit 0). Real AVR silicon sets + // PORF on power-up; the bare AVR8Sharp core boots MCUSR=0, so we seed PORF here + // to model a genuine cold boot. reset_reason reads MCUSR live (it is a @property + // getter now actually invoked), so the register must hold a realistic value. + private const int McusrAddr = 0x54; + private const byte McusrPorf = 0x01; + private string _hex = null!; [OneTimeSetUp] @@ -39,6 +47,7 @@ private ArduinoUnoSimulation Sim() { var uno = new ArduinoUnoSimulation(); uno.WithHex(_hex); + uno.Data[McusrAddr] = McusrPorf; // model real power-on (PORF set) return uno; } diff --git a/tests/integration/Tests/AVR/ConstFloorModTests.cs b/tests/integration/Tests/AVR/ConstFloorModTests.cs new file mode 100644 index 00000000..49a58286 --- /dev/null +++ b/tests/integration/Tests/AVR/ConstFloorModTests.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Constant-folded % must use Python's floor-mod (the remainder follows the sign of the +/// divisor), matching the // folding which already floors. C#'s % truncates toward zero +/// (remainder follows the dividend), so -7 % 2 folded to -1 instead of Python's 1. +/// +/// These are all compile-time constants, so they exercise the optimizer's BinaryOp.Mod +/// folding directly (no runtime division routine is involved). Runtime signed //,% is a +/// separate, still-open gap (see codegen backlog A38). +/// +[TestFixture] +public class ConstFloorModTests +{ + private static List Run(string body, int wantLines) + { + string src = + "from pymcu.types import int16\n" + + "from pymcu.hal.uart import UART\n\n\n" + + "def main():\n" + + " uart = UART(9600)\n" + + " uart.println(\"GO\")\n" + + body + + " while True:\n pass\n"; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, s => s.Replace("\r", "").Split('\n').Length >= wantLines + 2, maxMs: 3000); + + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var outp = new List(); + for (int i = start + 1; i < lines.Length && outp.Count < wantLines; i++) + { + var t = lines[i].Trim(); + if (t.Length > 0) outp.Add(t); + } + return outp; + } + + [Test] + public void NegDividend_FollowsDivisorSign() => + Run(" a: int16 = -7 % 2\n print(a)\n", 1) + .Should().Equal(new List { "1" }); // Python -7 % 2 = 1 (not C# -1) + + [Test] + public void NegDivisor_FollowsDivisorSign() => + Run(" a: int16 = 7 % -2\n print(a)\n", 1) + .Should().Equal(new List { "-1" }); // Python 7 % -2 = -1 + + [Test] + public void BothNegative() => + Run(" a: int16 = -7 % -2\n print(a)\n", 1) + .Should().Equal(new List { "-1" }); // Python -7 % -2 = -1 + + [Test] + public void NonDivisible_NegDividend() => + Run(" a: int16 = -7 % 3\n print(a)\n", 1) + .Should().Equal(new List { "2" }); // Python -7 % 3 = 2 (not C# -1) + + [Test] + public void NonNegativeUnaffected() => + Run(" a: int16 = 7 % 3\n print(a)\n", 1) + .Should().Equal(new List { "1" }); // unchanged +} diff --git a/tests/integration/Tests/AVR/DivMod16Tests.cs b/tests/integration/Tests/AVR/DivMod16Tests.cs new file mode 100644 index 00000000..4ed23a6e --- /dev/null +++ b/tests/integration/Tests/AVR/DivMod16Tests.cs @@ -0,0 +1,50 @@ +using Avr8Sharp.TestKit.Boards; +using FluentAssertions; +using NUnit.Framework; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration tests for fixtures/divmod16 — the divmod() built-in at 16-bit width. +/// +/// divmod(3000, 10) must yield (300, 0). A quotient of 300 exceeds 8 bits, so a +/// regression to the old uint8/__div8 lowering would narrow it (300 & 0xFF == 44); +/// seeing the exact "300" proves the wide result keeps its 16 bits. +/// +[TestFixture] +public class DivMod16Tests +{ + private static SimSession _session = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _session = new SimSession(PymcuCompiler.BuildFixture("divmod16")); + + private ArduinoUnoSimulation Boot() + { + var uno = _session.Reset(); + uno.RunUntilSerial(uno.Serial, "DM\n", maxMs: 200); + return uno; + } + + [Test] + public void Boot_SendsBanner() => + Boot().Serial.Text.Should().Contain("DM"); + + [Test] + public void WideQuotient_NotNarrowedToEightBits() + { + // 3007 // 10 == 300; the old __div8 lowering would print 44. + var uno = Boot(); + uno.RunUntilSerial(uno.Serial, s => s.Contains("300\n"), maxMs: 400); + uno.Serial.Text.Should().Contain("300", "3007 // 10 = 300 at 16-bit width"); + } + + [Test] + public void Remainder_Correct() + { + // 3000 % 10 == 0. + var uno = Boot(); + uno.RunUntilSerial(uno.Serial, s => s.Contains("300\n0\n"), maxMs: 400); + uno.Serial.Text.Should().Contain("300\n0", "3000 % 10 = 0"); + } +} diff --git a/tests/integration/Tests/AVR/DspStressTests.cs b/tests/integration/Tests/AVR/DspStressTests.cs new file mode 100644 index 00000000..0fb71048 --- /dev/null +++ b/tests/integration/Tests/AVR/DspStressTests.cs @@ -0,0 +1,57 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; +using Avr8Sharp.TestKit; +using AVR8Sharp.Core.Peripherals; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Stress fixture exercising every AVR codegen optimization at once under heavy +/// register pressure: four ADC channels (four (hi<<8)|lo byte-packs), four +/// sliding-window accumulators (variable-index array Z-CSE + many uint16 temps), +/// the divmod() builtin, and decimal printing (divmod fusion). +/// +/// With all four channels held at a constant ADC count of 500, every running +/// average converges to 500 and divmod(500, 10) yields (50, 0). Seeing those +/// exact values proves the combined optimizations preserve the arithmetic. +/// +[TestFixture] +public class DspStressTests +{ + private static string _hex = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _hex = PymcuCompiler.BuildFixture("dsp-stress"); + + private static ArduinoUnoSimulation SimAllChannelsAt(double adcCount) + { + var uno = new ArduinoUnoSimulation(); + uno.WithHex(_hex); + uno.AddAdc(AvrAdc.AdcConfig, out var adc); + double v = adcCount / 1024.0 * 5.0; + adc.ChannelValues[0] = v; + adc.ChannelValues[1] = v; + adc.ChannelValues[2] = v; + adc.ChannelValues[3] = v; + return uno; + } + + [Test] + public void RunningAverages_ConvergeTo500() + { + var uno = SimAllChannelsAt(500); + // Enough output for the 8-sample windows to fill and converge. + uno.RunUntilSerialBytes(uno.Serial, 200, maxMs: 8000); + uno.Serial.Should().Contain("500", "each channel's running average converges to 500"); + } + + [Test] + public void DivModBuiltin_500_Yields_50_And_0() + { + var uno = SimAllChannelsAt(500); + uno.RunUntilSerialBytes(uno.Serial, 200, maxMs: 8000); + // divmod(500, 10) == (50, 0): the quotient line "50" then the remainder line "0". + uno.Serial.Should().Contain("50", "divmod(500, 10) quotient is 50"); + } +} diff --git a/tests/integration/Tests/AVR/FidelityProbeTests.cs b/tests/integration/Tests/AVR/FidelityProbeTests.cs new file mode 100644 index 00000000..024d9333 --- /dev/null +++ b/tests/integration/Tests/AVR/FidelityProbeTests.cs @@ -0,0 +1,2933 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Bug-hunting probes for Python language fidelity. Each builds a tiny program, seeds a runtime +/// value over UART (so nothing constant-folds), and checks the printed lines against the Python +/// oracle. A failure here is a silent miscompilation, not a crash. +/// +[TestFixture] +public class FidelityProbeTests +{ + private static int NL(string s) { int n = 0; foreach (var c in s) if (c == '\n') n++; return n; } + + private static List RunSeed(string body, int seed, int expectedLines) + { + string src = + "from pymcu.types import uint8\n" + + "from pymcu.hal.uart import UART\n\n\n" + + body + "\n" + + "def main():\n" + + " uart = UART(9600)\n" + + " uart.println(\"GO\")\n" + + " s: uint8 = uart.read_blocking()\n" + + " run(s)\n" + + " while True:\n pass\n"; + + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte((byte)seed); + uno.RunUntilSerial(uno.Serial, t => NL(t) >= expectedLines + 1, maxMs: 6000); + + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var got = new List(); + for (int i = start + 1; i < lines.Length && got.Count < expectedLines; i++) + if (long.TryParse(lines[i].Trim(), out long v)) got.Add(v); + return got; + } + + [Test] + public void FiveLevelInheritanceChain() + { + // Five levels (L0..L4), each level's __init__ chaining via super().__init__(), fields + // inherited across all five, a leaf override of kind(), a mutating method (go) invoked + // twice, and template-method virtual dispatch (go calls self.kind()). + const string body = + "from pymcu.types import uint16\n\n" + + "class L0:\n def __init__(self, a: uint8):\n self._a = a\n self._n = 0\n" + + " def kind(self) -> uint16:\n return 0\n" + + " def go(self) -> uint16:\n self._n = self._n + 1\n return self.kind() + self._n\n\n" + + "class L1(L0):\n def __init__(self, a: uint8):\n super().__init__(a)\n self._b = 2\n\n" + + "class L2(L1):\n def __init__(self, a: uint8):\n super().__init__(a)\n self._c = 3\n\n" + + "class L3(L2):\n def __init__(self, a: uint8):\n super().__init__(a)\n self._d = 4\n\n" + + "class L4(L3):\n def __init__(self, a: uint8):\n super().__init__(a)\n" + + " def kind(self) -> uint16:\n return uint16(self._a) * 100 + self._b + self._c + self._d\n\n" + + "def run(s: uint8):\n x = L4(s)\n" + + " print(x.go())\n" + // n=1: 5*100+2+3+4 + 1 = 510 + " print(x.go())\n" + // n=2: 509 + 2 = 511 + " print(x._a)\n" + // 5 + " print(x._d)\n"; // 4 + RunSeed(body, 5, 4).Should().Equal(510, 511, 5, 4); + } + + [Test] + public void DhtStyleThreeLevelInheritance() + { + // Real-world 3-level chain (SensorBase -> GenericDht -> Dht11): super().__init__ chains, + // fields inherited across 3 levels, a 3-field slot, a mutating method, and a template- + // method virtual dispatch (sample() calls self.read_raw(), overridden at the leaf). + const string body = + "from pymcu.types import uint16\n\n" + + "class SensorBase:\n" + + " def __init__(self, pin: uint8):\n self._pin = pin\n self._reads = 0\n\n" + + " def read_raw(self) -> uint16:\n return 0\n\n" + + " def sample(self) -> uint16:\n self._reads = self._reads + 1\n return self.read_raw()\n\n" + + "class GenericDht(SensorBase):\n" + + " def __init__(self, pin: uint8, kind: uint8):\n super().__init__(pin)\n self._kind = kind\n\n" + + " def read_raw(self) -> uint16:\n return uint16(self._pin) * 10 + self._kind\n\n" + + "class Dht11(GenericDht):\n" + + " def __init__(self, pin: uint8):\n super().__init__(pin, 1)\n\n" + + " def read_raw(self) -> uint16:\n return uint16(self._pin) * 100 + self._kind + self._reads\n\n" + + "def run(s: uint8):\n" + + " d = Dht11(s)\n" + + " print(d.sample())\n" + // reads=1: 5*100 + 1 + 1 = 502 + " print(d.sample())\n" + // reads=2: 5*100 + 1 + 2 = 503 + " print(d._pin)\n" + // 5 + " print(d._kind)\n"; // 1 + RunSeed(body, 5, 4).Should().Equal(502, 503, 5, 1); + } + + [Test] + public void ChainedComparison() + { + // 1 < s < 10 ; 1 < s < 4 ; 0 < s <= 5 with s=5 -> 1, 0, 1 + const string body = + "def run(s: uint8):\n" + + " print(1 if 1 < s < 10 else 0)\n" + + " print(1 if 1 < s < 4 else 0)\n" + + " print(1 if 0 < s <= 5 else 0)\n"; + RunSeed(body, 5, 3).Should().Equal(1, 0, 1); + } + + [Test] + public void ShortCircuit_Or_SkipsRhs() + { + // bump() returns 1 and increments a module counter. `True or bump()` must NOT call bump. + // `False or bump()` must call it. Print the counter after each. + const string body = + "hits: uint8 = 0\n\n" + + "def bump() -> uint8:\n" + + " global hits\n" + + " hits = hits + 1\n" + + " return 1\n\n" + + "def run(s: uint8):\n" + + " global hits\n" + + " a: bool = (s > 0) or (bump() > 0)\n" + // s>0 True -> bump skipped + " print(hits)\n" + // 0 + " b: bool = (s > 100) and (bump() > 0)\n" + // s>100 False -> bump skipped + " print(hits)\n" + // 0 + " c: bool = (s > 100) or (bump() > 0)\n" + // False or -> bump runs + " print(hits)\n"; // 1 + RunSeed(body, 5, 3).Should().Equal(0, 0, 1); + } + + [Test] + public void MultipleAssignment_EvalsOnce() + { + // a = b = s + 1 -> both a and b equal s+1, expr evaluated once. + const string body = + "def run(s: uint8):\n" + + " a: uint8 = 0\n" + + " b: uint8 = 0\n" + + " a = b = s + 1\n" + + " print(a)\n" + + " print(b)\n"; + RunSeed(body, 5, 2).Should().Equal(6, 6); + } + + [Test] + public void AugAssign_OnArrayElement() + { + // arr[i] += s for a runtime-ish constant index, verify it reads then writes. + const string body = + "def run(s: uint8):\n" + + " arr: uint8[3] = [10, 20, 30]\n" + + " arr[1] += s\n" + // 20 + 5 = 25 + " arr[2] -= s\n" + // 30 - 5 = 25 + " print(arr[0])\n" + // 10 + " print(arr[1])\n" + // 25 + " print(arr[2])\n"; // 25 + RunSeed(body, 5, 3).Should().Equal(10, 25, 25); + } + + [Test] + public void DefaultAndKeywordArgs() + { + const string body = + "def addk(a: uint8, b: uint8 = 10, c: uint8 = 100) -> uint8:\n" + + " return a + b + c\n\n" + + "def run(s: uint8):\n" + + " print(addk(s))\n" + // 5+10+100 = 115 + " print(addk(s, c=1))\n" + // 5+10+1 = 16 + " print(addk(s, 2))\n"; // 5+2+100 = 107 + RunSeed(body, 5, 3).Should().Equal(115, 16, 107); + } + + [Test] + public void WhileBreakContinue() + { + // sum odd i in 1..s, break once i exceeds s. s=5 -> 1+3+5 = 9 + const string body = + "def run(s: uint8):\n" + + " total: uint8 = 0\n" + + " i: uint8 = 0\n" + + " while True:\n" + + " i += 1\n" + + " if i > s:\n" + + " break\n" + + " if i % 2 == 0:\n" + + " continue\n" + + " total += i\n" + + " print(total)\n"; + RunSeed(body, 5, 1).Should().Equal(9); + } + + [Test] + public void SignedArithmeticShift() + { + // int8(-8) >> 1 = -4 (arithmetic shift keeps sign); seed only gates execution. + const string body = + "from pymcu.types import int8\n\n" + + "def run(s: uint8):\n" + + " x: int8 = int8(0) - int8(s) - 3\n" + // -(5)-3 = -8 + " y: int8 = x >> 1\n" + + " print(y)\n"; // -4 + RunSeed(body, 5, 1).Should().Equal(-4); + } + + [Test] + public void MixedWidthMultiply_WidensToTarget() + { + // uint8 * uint8 assigned to uint16 must compute the full 16-bit product (300), not + // wrap at 8 bits (44). This is the classic AOT fixed-width promotion trap. + const string body = + "from pymcu.types import uint16\n\n" + + "def run(s: uint8):\n" + + " a: uint8 = 60\n" + + " r: uint16 = a * s\n" + // 60*5 = 300 + " print(r)\n"; + RunSeed(body, 5, 1).Should().Equal(300); + } + + [Test] + public void Uint8Wraparound() + { + // Documented fixed-width wrap: 250+5=255, 255+5 = 260 & 0xFF = 4. + const string body = + "def run(s: uint8):\n" + + " w: uint8 = 250 + s\n" + + " print(w)\n" + // 255 + " w = w + s\n" + + " print(w)\n"; // 4 + RunSeed(body, 5, 2).Should().Equal(255, 4); + } + + [Test] + public void BoolArithmetic() + { + // Comparisons are 0/1 integers in arithmetic: (s>0)+(s>3)+(s>100) = 1+1+0 = 2. + const string body = + "def run(s: uint8):\n" + + " cnt: uint8 = (s > 0) + (s > 3) + (s > 100)\n" + + " print(cnt)\n"; + RunSeed(body, 5, 1).Should().Equal(2); + } + + [Test] + public void TupleUnpackFromInlineReturn() + { + const string body = + "@inline\n" + + "def divmod2(a: uint8, b: uint8) -> tuple[uint8, uint8]:\n" + + " return a // b, a % b\n\n" + + "def run(s: uint8):\n" + + " q: uint8 = 0\n" + + " rem: uint8 = 0\n" + + " q, rem = divmod2(s * 5, 7)\n" + // 25//7=3, 25%7=4 + " print(q)\n" + + " print(rem)\n"; + RunSeed(body, 5, 2).Should().Equal(3, 4); + } + + [Test] + public void NestedLoopInnerBreak() + { + const string body = + "def run(s: uint8):\n" + + " acc: uint8 = 0\n" + + " for i in range(3):\n" + + " for j in range(10):\n" + + " if j >= s:\n" + + " break\n" + + " acc += 1\n" + + " print(acc)\n"; // 3 * min(10, s) = 15 + RunSeed(body, 5, 1).Should().Equal(15); + } + + [Test] + public void RuntimeBoundRange() + { + const string body = + "def run(s: uint8):\n" + + " acc: uint8 = 0\n" + + " for i in range(s):\n" + + " acc += i\n" + + " print(acc)\n" + // 0+1+2+3+4 = 10 + " c: uint8 = 0\n" + + " for i in range(s):\n" + + " if i * i > s:\n" + + " break\n" + + " c += 1\n" + + " print(c)\n"; // i=0,1,2 ok; 3*3=9>5 break -> 3 + RunSeed(body, 5, 2).Should().Equal(10, 3); + } + + [Test] + public void RuntimeArrayIndex() + { + const string body = + "def run(s: uint8):\n" + + " arr: uint8[5] = [0, 0, 0, 0, 0]\n" + + " idx: uint8 = s - 3\n" + // 2 + " arr[idx] = 42\n" + + " print(arr[2])\n" + // 42 + " arr[idx] += 8\n" + + " print(arr[idx])\n"; // 50 + RunSeed(body, 5, 2).Should().Equal(42, 50); + } + + [Test] + public void SignedFloorDivMod_Negatives() + { + // Python floor semantics: -7//2 = -4, -7%2 = 1, -7%3 = 2. + const string body = + "from pymcu.types import int8\n\n" + + "def run(s: uint8):\n" + + " a: int8 = int8(0) - int8(s) - 2\n" + // -7 + " print(a // 2)\n" + + " print(a % 2)\n" + + " print(a % 3)\n"; + RunSeed(body, 5, 3).Should().Equal(-4, 1, 2); + } + + [Test] + public void BitwiseNot_Uint8() + { + const string body = + "def run(s: uint8):\n" + + " n: uint8 = s\n" + + " print(uint8(~n))\n"; // ~5 = 250 in 8-bit + RunSeed(body, 5, 1).Should().Equal(250); + } + + [Test] + public void VariableShiftAmount() + { + const string body = + "from pymcu.types import uint16\n\n" + + "def run(s: uint8):\n" + + " print(uint16(1) << s)\n" + // 32 + " print(s << 1)\n" + // 10 + " print(uint8(128) >> (s - 3))\n"; // 128>>2 = 32 + RunSeed(body, 5, 3).Should().Equal(32, 10, 32); + } + + [Test] + public void Uint16RuntimeLoopAccumulation() + { + const string body = + "from pymcu.types import uint16\n\n" + + "def run(s: uint8):\n" + + " tot: uint16 = 0\n" + + " for i in range(s):\n" + + " tot += 100\n" + + " print(tot)\n"; // 5*100 = 500 (overflows 8-bit) + RunSeed(body, 5, 1).Should().Equal(500); + } + + [Test] + public void MixedSignednessComparison() + { + // int8(-1) < int8(5) is True (Python: -1 < 5). Guards against unsigned reinterpretation. + const string body = + "from pymcu.types import int8\n\n" + + "def run(s: uint8):\n" + + " neg: int8 = int8(0) - 1\n" + + " print(1 if neg < int8(s) else 0)\n"; // 1 + RunSeed(body, 5, 1).Should().Equal(1); + } + + [Test] + public void BytesLiteralIndexAndLen() + { + const string body = + "def run(s: uint8):\n" + + " data = b\"ABCDE\"\n" + + " print(data[0])\n" + // 'A' = 65 + " print(len(data))\n"; // 5 + RunSeed(body, 5, 2).Should().Equal(65, 5); + } + + [Test] + public void GlobalMutationAcrossCalls() + { + // Regression guard for the PropagateCopies Call-dst fix: a global bumped inside a callee + // must read its accumulated value after the call, not the pre-call constant. + const string body = + "from pymcu.types import uint16\n\n" + + "counter: uint16 = 0\n\n" + + "def tick(n: uint8):\n" + + " global counter\n" + + " counter = counter + n\n\n" + + "def run(s: uint8):\n" + + " tick(s)\n" + + " tick(s)\n" + + " print(counter)\n"; // 10 + RunSeed(body, 5, 1).Should().Equal(10); + } + + [Test] + public void Uint32FullArithmetic() + { + const string body = + "from pymcu.types import uint32\n\n" + + "def run(s: uint8):\n" + + " base: uint32 = uint32(s) * 1000000\n" + + " print(base + 12345)\n" + + " print(base - 6999999)\n" + + " print(base // 7)\n" + + " print(base % 999)\n" + + " print(base >> 4)\n" + + " print(base & 0xFFFF)\n"; + const long b = 7 * 1000000L; + RunSeed(body, 7, 6).Should().Equal( + b + 12345, b - 6999999, b / 7, b % 999, b >> 4, b & 0xFFFF); + } + + [Test] + public void BoolOrShortCircuit_AsCondition() + { + // Regression: `A or B` used directly as an if/ternary condition must short-circuit to + // TRUE when A is true, not fall through to evaluate B. CollapseBoolJumps used to fuse B's + // comparison into the jump past the OR's end label, dropping the A-true path. All six + // forms (with/without parens, either operand order, via a bool var) must be True for s=7. + const string body = + "def run(s: uint8):\n" + + " print(1 if (s > 5 and s < 10) else 0)\n" + + " print(1 if (s > 5 and s < 10) or s == 0 else 0)\n" + + " print(1 if s == 0 or (s > 5 and s < 10) else 0)\n" + + " print(1 if (s > 5) or s == 0 else 0)\n" + + " b: bool = (s > 5 and s < 10) or s == 0\n" + + " print(1 if b else 0)\n" + + " print(1 if s > 5 and s < 10 or s == 0 else 0)\n"; + RunSeed(body, 7, 6).Should().Equal(1, 1, 1, 1, 1, 1); + } + + [Test] + public void BoolCompound_AndOrNot() + { + const string body = + "def run(s: uint8):\n" + + " print(1 if s < 3 and s < 10 else 0)\n" + // 0 + " print(1 if not (s < 3 or s > 5) else 0)\n" + // 0 + " print(1 if (s > 5 or s < 1) and (s < 10 or s == 0) else 0)\n" + // 1 + " print(1 if s > 100 or s > 50 or s > 5 else 0)\n" + // 1 + " print(1 if s < 1 or s < 2 or s < 3 else 0)\n" + // 0 + " print(1 if (s > 10 and s < 20) or s == 7 else 0)\n" + // 1 + " print(1 if s > 10 or s < 5 and s > 0 else 0)\n" + // 0 + " print(1 if not s == 7 else 0)\n" + // 0 + " i: uint8 = 0\n" + + " while i < s and i < 3:\n" + + " i += 1\n" + + " print(i)\n"; // 3 + RunSeed(body, 7, 9).Should().Equal(0, 0, 1, 1, 0, 1, 0, 0, 3); + } + + [Test] + public void AndOr_ReturnOperand_PythonSemantics() + { + // Python: `a or b`/`a and b` evaluate to an OPERAND, not a coerced bool. + const string body = + "def run(s: uint8):\n" + + " print(s or 100)\n" + // s truthy -> 7 + " print(0 or s)\n" + // 0 falsy -> 7 + " print(s and 100)\n" + // s truthy -> 100 + " print(0 and s)\n" + // 0 falsy -> 0 + " a: uint8 = 0\n" + + " print(a or s or 200)\n" + // 0 or 7 -> 7 + " print(a and 5 or s)\n"; // (0 and 5)=0, 0 or 7 -> 7 + RunSeed(body, 7, 6).Should().Equal(7, 7, 100, 0, 7, 7); + } + + [Test] + public void IfStatement_CompoundConditions() + { + const string body = + "def run(s: uint8):\n" + + " r: uint8 = 9\n" + + " if (s > 5) or (s == 0):\n r = 1\n else:\n r = 2\n" + + " print(r)\n" + // 1 + " if (s > 10) or (s == 0):\n r = 3\n else:\n r = 4\n" + + " print(r)\n" + // 4 + " if (s > 5) and (s < 10):\n r = 5\n else:\n r = 6\n" + + " print(r)\n" + // 5 + " if (s > 10) and (s < 20):\n r = 7\n else:\n r = 8\n" + + " print(r)\n" + // 8 + " if s < 3:\n r = 10\n elif s > 5 or s == 4:\n r = 11\n else:\n r = 12\n" + + " print(r)\n"; // 11 + RunSeed(body, 7, 5).Should().Equal(1, 4, 5, 8, 11); + } + + [Test] + public void PrintSignedCastDirect() + { + // Regression: print(int8(x)) must format signed. CoalesceInstructions used to retarget + // `copy u8 -> i8; copy i8 -> i16` into `copy u8 -> i16`, zero-extending the value so a + // direct print of a signed cast showed the unsigned byte (200 instead of -56). + const string body = + "from pymcu.types import int8\n\n" + + "def run(s: uint8):\n" + + " print(int8(s))\n" + // -56 (direct cast in print) + " x: int8 = int8(s)\n" + + " print(x)\n" + // -56 (via typed var) + " y: int8 = int8(s)\n" + + " print(y + 0)\n" + // -56 (arithmetic) + " print(int8(200))\n"; // -56 (constant cast) + RunSeed(body, 200, 4).Should().Equal(-56, -56, -56, -56); + } + + [Test] + public void WidthCasts_TruncateSignZeroExtend() + { + const string body = + "from pymcu.types import int8, uint16, int16, uint32\n\n" + + "def run(s: uint8):\n" + + " big: uint16 = uint16(s) + 300\n" + + " print(uint8(big))\n" + // 500 -> 244 (truncate) + " print(int8(s))\n" + // 200 -> -56 (reinterpret) + " neg: int8 = int8(0) - 1\n" + + " print(uint16(neg))\n" + // -1 -> 65535 (sign-extend) + " n2: int8 = int8(s)\n" + + " print(int16(n2))\n" + // -56 -> -56 (sign-extend) + " print(int16(s))\n" + // 200 -> 200 (zero-extend) + " print(uint32(s) * 100000)\n"; // 200*100000 = 20_000_000 + RunSeed(body, 200, 6).Should().Equal(244, -56, 65535, -56, 200, 20000000); + } + + [Test] + public void FloatArithmeticAndConversions() + { + const string src = + "from pymcu.types import uint8, int16\n" + + "from pymcu.hal.uart import UART\n\n\n" + + "def main():\n" + + " uart = UART(9600)\n" + + " uart.println(\"GO\")\n" + + " s: uint8 = uart.read_blocking()\n" + + " f: float = float(s) / 4.0\n" + + " print(f)\n" + // 2.5 + " g: float = f * 2.0\n" + + " print(g)\n" + // 5.0 + " print(int16(f * 100.0))\n" + // 250 + " h: float = float(s) + 0.5\n" + + " print(int16(h))\n" + // 10 + " n: int16 = int16(0) - int16(s) * 30\n" + + " print(n)\n" + // -300 + " print(uint8(int16(s) * 30))\n" + // 44 + " print(1 if f < g else 0)\n" + // 1 + " while True:\n pass\n"; + + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(10); + uno.RunUntilSerial(uno.Serial, t => NL(t) >= 8, maxMs: 6000); + + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var got = new List(); + for (int i = start + 1; i < lines.Length && got.Count < 7; i++) + if (lines[i].Trim().Length > 0) got.Add(lines[i].Trim()); + + got[0].Should().StartWith("2.5"); + got[1].Should().StartWith("5.0"); + got[2].Should().Be("250"); + got[3].Should().Be("10"); + got[4].Should().Be("-300"); + got[5].Should().Be("44"); + got[6].Should().Be("1"); + } + + [Test] + public void TrueDivisionReturnsFloat() + { + // Python 3: `/` always returns float, even for two ints. 5 / 2 == 2.5, 5 / 5 == 1.0. + // PyMCU promotes integer operands to float (and warns that float routines are linked). + const string src = + "from pymcu.types import uint8\n" + + "from pymcu.hal.uart import UART\n\n\n" + + "def main():\n" + + " uart = UART(9600)\n" + + " uart.println(\"GO\")\n" + + " s: uint8 = uart.read_blocking()\n" + + " print(s / 2)\n" + // 5 -> 2.5 + " print(s / 5)\n" + // 5 -> 1.0 + " while True:\n pass\n"; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(5); + uno.RunUntilSerial(uno.Serial, t => NL(t) >= 3, maxMs: 6000); + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var got = new List(); + for (int i = start + 1; i < lines.Length && got.Count < 2; i++) + if (lines[i].Trim().Length > 0) got.Add(lines[i].Trim()); + got[0].Should().StartWith("2.5"); + got[1].Should().StartWith("1.0"); + } + + [Test] + public void Int8SramArray_SignExtendsOnLoad() + { + // Regression: a runtime index forces an int8 array into SRAM. Loading an element for a + // wider use (print -> i16) must sign-extend. CoalesceInstructions used to retarget the + // ArrayLoad's int8 dst onto the int16 temp, leaving the high byte unset (-5 read as 251). + const string body = + "from pymcu.types import int8\n\n" + + "def run(s: uint8):\n" + + " arr: int8[4] = [0, 0, 0, 0]\n" + + " arr[0] = int8(0) - int8(s)\n" + + " arr[1] = int8(s)\n" + + " idx: uint8 = s - 4\n" + + " print(arr[idx])\n" + // arr[1] = 5 + " print(arr[0])\n" + // -5 + " j: uint8 = s - 5\n" + + " print(arr[j])\n"; // arr[0] = -5 + RunSeed(body, 5, 3).Should().Equal(5, -5, -5); + } + + [Test] + public void SignedThroughArraysAndFunctions() + { + const string body = + "from pymcu.types import int8, int16\n\n" + + "def neg_of(x: int8) -> int8:\n" + + " return int8(0) - x\n\n" + + "def run(s: uint8):\n" + + " a: int8 = int8(0) - int8(s)\n" + // -5 + " print(neg_of(a))\n" + // 5 + " print(neg_of(int8(s)))\n" + // -5 + " arr: int8[4] = [0, 0, 0, 0]\n" + + " arr[0] = int8(0) - int8(s)\n" + // -5 + " arr[1] = int8(s)\n" + // 5 + " idx: uint8 = s - 4\n" + // 1 + " arr[2] = arr[idx]\n" + // 5 + " print(arr[0])\n" + // -5 + " print(arr[2])\n" + // 5 + " total: int16 = 0\n" + + " for i in range(4):\n" + + " total += int16(arr[i])\n" + + " print(total)\n"; // -5+5+5+0 = 5 + RunSeed(body, 5, 5).Should().Equal(5, -5, -5, 5, 5); + } + + [Test] + public void AnnotatedDeclRuntimeArrayIndex() + { + // Regression: `v: T = arr[idx]` (a typed declaration whose initializer reads a + // runtime-indexed array) used to error "subscript must be compile-time constant", + // while `v: T = 0; v = arr[idx]` worked. ScanForVariableIndexedArrays now scans + // VarDecl initializers (and for-loop bodies), so arr is marked SRAM-indexed. + const string body = + "def run(s: uint8):\n" + + " arr: uint8[4] = [10, 20, 30, 40]\n" + + " idx: uint8 = s - 4\n" + // 1 + " v: uint8 = arr[idx]\n" + + " print(v)\n"; // arr[1] = 20 + RunSeed(body, 5, 1).Should().Equal(20); + } + + [Test] + public void InlineComputedArrayIndex_InExpression() + { + // Regression (AvrLinearScan): the live interval of a temp defined by an ArrayLoad was + // invisible (the array ops were missing from the liveness walk), so an earlier load's + // result shared R16 with a later load's inline index and got clobbered. + // `arr[idx] + arr[s - 5]` returned just the second element. + const string body = + "def run(s: uint8):\n" + + " arr: uint8[5] = [10, 20, 30, 40, 50]\n" + + " idx: uint8 = s - 4\n" + + " print(arr[idx] + arr[s - 5])\n" + // 20+10 = 30 + " print(arr[s - 5] + arr[idx])\n" + // 10+20 = 30 + " print(arr[s - 4])\n"; // 20 + RunSeed(body, 5, 3).Should().Equal(30, 30, 20); + } + + [Test] + public void BytearrayAndUint16_TwoLoadsInExpression() + { + const string body = + "from pymcu.types import uint16\n\n" + + "def sum2(buf: bytearray, i: uint8) -> uint8:\n" + + " return buf[i] + buf[i + 1]\n\n" + + "def run(s: uint8):\n" + + " data: uint8[4] = [10, 20, 30, 40]\n" + + " print(sum2(data, s))\n" + // buf[1]+buf[2] = 50 + " w: uint16[3] = [100, 200, 300]\n" + + " k: uint16 = w[s] + w[s - 1]\n" + + " print(k)\n"; // w[1]+w[0] = 300 + RunSeed(body, 1, 2).Should().Equal(50, 300); + } + + [Test] + public void TwoRuntimeArrayLoads_StoredIndices() + { + // Two SRAM array loads with pre-stored runtime indices combine correctly. + const string body = + "def run(s: uint8):\n" + + " arr: uint8[5] = [10, 20, 30, 40, 50]\n" + + " i: uint8 = s - 4\n" + // 1 + " j: uint8 = s - 5\n" + // 0 + " print(arr[i] + arr[j])\n" + // 20+10 = 30 + " k: uint8 = s - 3\n" + // 2 + " print(arr[i] + arr[k])\n"; // 20+30 = 50 + RunSeed(body, 5, 2).Should().Equal(30, 50); + } + + [Test] + public void TwoIndirectCallsInExpression() + { + const string body = + "from pymcu.types import Callable\n\n" + + "def add_one(x: uint8) -> uint8:\n" + + " return x + 1\n\n" + + "def add_two(x: uint8) -> uint8:\n" + + " return x + 2\n\n" + + "def run(s: uint8):\n" + + " fn: Callable = add_one\n" + + " fn2: Callable = add_two\n" + + " a: uint8 = fn(s) + fn2(s)\n" + // 4 + 5 = 9 + " print(a)\n"; + var got = RunSeed(body, 3, 1); + TestContext.WriteLine("GOT: " + string.Join(",", got)); + got.Should().Equal(9); + } + + [Test] + public void MultiFieldZca_MutateTwoInstances() + { + const string body = + "from pymcu.types import uint16\n\n" + + "class Point:\n" + + " def __init__(self, x: uint8, y: uint8):\n" + + " self.x = x\n" + + " self.y = y\n\n" + + " def move(self, dx: uint8, dy: uint8):\n" + + " self.x = self.x + dx\n" + + " self.y = self.y + dy\n\n" + + " def total(self) -> uint16:\n" + + " return uint16(self.x) + uint16(self.y)\n\n" + + "def run(s: uint8):\n" + + " p = Point(s, s + 1)\n" + + " q = Point(s + 2, s + 3)\n" + + " p.move(10, 20)\n" + + " q.move(s, s)\n" + + " print(p.total())\n" + // 15+26 = 41 + " print(q.total())\n" + // 12+13 = 25 + " print(p.x)\n" + // 15 + " print(q.y)\n"; // 13 + var got = RunSeed(body, 5, 4); + TestContext.WriteLine("GOT: " + string.Join(",", got)); + got.Should().Equal(41, 25, 15, 13); + } + + [Test] + public void MultiFieldZca_WideField() + { + // A slot (multi-field) ZCA with a uint16 field must store/load all bytes, not just the + // low one. Previously total=1500 read back as 220 (1500 & 0xFF). Mixed with a uint8 field + // to exercise non-trivial byte offsets, via a method and direct access. + const string body = + "from pymcu.types import uint16\n\n" + + "class Acc:\n" + + " def __init__(self, base: uint16, tag: uint8):\n" + + " self.total = base\n" + + " self.tag = tag\n\n" + + " def add(self, v: uint16):\n" + + " self.total = self.total + v\n\n" + + " def get(self) -> uint16:\n" + + " return self.total\n\n" + + "def run(s: uint8):\n" + + " a = Acc(uint16(s) * 100, s)\n" + + " a.add(500)\n" + + " print(a.get())\n" + // 1000+500 = 1500 + " print(a.total)\n" + // 1500 (direct) + " print(a.tag)\n"; // 10 (uint8 field after a uint16) + RunSeed(body, 10, 3).Should().Equal(1500, 1500, 10); + } + + [Test] + public void EnumArithmeticAndCompare() + { + const string body = + "from pymcu.types import Enum\n\n" + + "class Color(Enum):\n" + + " RED = 1\n" + + " GREEN = 2\n" + + " BLUE = 4\n\n" + + "def run(s: uint8):\n" + + " print(Color.RED + Color.BLUE)\n" + // 5 + " print(1 if s == Color.GREEN else 0)\n" + // 1 (s=2) + " print(Color.RED | Color.GREEN | Color.BLUE)\n" + // 7 + " print(s + Color.RED)\n"; // 3 + RunSeed(body, 2, 4).Should().Equal(5, 1, 7, 3); + } + + [Test] + public void ListAppendIndexIterate() + { + const string body = + "def run(s: uint8):\n" + + " lst: list[uint8] = []\n" + + " lst.append(s)\n" + + " lst.append(s + 1)\n" + + " lst.append(s + 2)\n" + + " print(len(lst))\n" + // 3 + " print(lst[0])\n" + // 5 + " print(lst[2])\n" + // 7 + " lst[1] = 99\n" + + " print(lst[1])\n" + // 99 + " total: uint8 = 0\n" + + " for v in lst:\n" + + " total += v\n" + + " print(total)\n"; // 5+99+7 = 111 + var got = RunSeed(body, 5, 5); + TestContext.WriteLine("GOT=" + string.Join(",", got)); + got.Should().Equal(3, 5, 7, 99, 111); + } + + [Test] + public void NegativeArrayIndex() + { + const string body = + "def run(s: uint8):\n" + + " arr: uint8[4] = [10, 20, 30, 40]\n" + + " print(arr[-1])\n" + // 40 + " print(arr[-2])\n"; // 30 + RunSeed(body, 0, 2).Should().Equal(40, 30); + } + + [Test] + public void SliceInForLoop() + { + const string body = + "def run(s: uint8):\n" + + " arr: uint8[5] = [10, 20, 30, 40, 50]\n" + + " total: uint8 = 0\n" + + " for v in arr[1:4]:\n" + + " total += v\n" + + " print(total)\n"; // 20+30+40 = 90 + RunSeed(body, 0, 1).Should().Equal(90); + } + + [Test] + public void ListUint16Elements() + { + const string body = + "from pymcu.types import uint16\n\n" + + "def run(s: uint8):\n" + + " lst: list[uint16] = []\n" + + " lst.append(uint16(s) * 1000)\n" + // 3000 + " lst.append(uint16(s) * 100)\n" + // 300 + " print(lst[0])\n" + // 3000 + " print(lst[1])\n" + // 300 + " lst[0] = 50000\n" + + " print(lst[0])\n" + // 50000 + " tot: uint16 = 0\n" + + " for v in lst:\n" + + " tot += v\n" + + " print(tot)\n"; // 50000+300 = 50300 + var got = RunSeed(body, 3, 4); + TestContext.WriteLine("GOT=" + string.Join(",", got)); + got.Should().Equal(3000, 300, 50000, 50300); + } + + [Test] + public void AugAssignSlotField_AndBuiltins() + { + const string body = + "from pymcu.types import int8\n\n" + + "class Box:\n" + + " def __init__(self, x: uint8, y: uint8):\n" + + " self.x = x\n" + + " self.y = y\n\n" + + "def run(s: uint8):\n" + + " b = Box(s, s + 10)\n" + + " b.x += 3\n" + // aug-assign on a slot field outside a method + " b.y -= 2\n" + + " print(b.x)\n" + // 8 + " print(b.y)\n" + // 13 + " print(max(b.x, b.y))\n" + // 13 + " print(min(b.x, b.y))\n" + // 8 + " print(abs(int8(0) - int8(s)))\n"; // abs(-5) = 5 + RunSeed(body, 5, 5).Should().Equal(8, 13, 13, 8, 5); + } + + [Test] + public void SignedCompareAgainstZero() + { + const string body = + "from pymcu.types import int8\n\n" + + "def run(s: uint8):\n" + + " n: int8 = int8(0) - int8(s)\n" + // -5 + " print(1 if n < 0 else 0)\n" + // 1 + " print(1 if n < int8(1) else 0)\n" + // 1 + " m: int8 = int8(s)\n" + // 5 + " print(1 if m < 0 else 0)\n"; // 0 + RunSeed(body, 5, 3).Should().Equal(1, 1, 0); + } + + [Test] + public void Uint32AsSecondArg() + { + // A 32-bit value passed as a non-first argument must get its own 4-register block and not + // clobber arg0 (the old fixed [R24,R22,R20,R18] layout put it in R22:R23:R24:R25). + const string body = + "from pymcu.types import uint32\n\n" + + "def combine(a: uint8, b: uint32) -> uint32:\n" + + " return b + uint32(a)\n\n" + + "def run(s: uint8):\n" + + " r: uint32 = combine(s, uint32(s) * 1000000)\n" + // 10_000_000 + 10 + " print(r)\n"; + RunSeed(body, 10, 1).Should().Equal(10000010); + } + + [Test] + public void MultiFieldZca_Uint32Field() + { + // The mutator bump(self, d: uint32) passes d as a 32-bit second argument; with the ABI + // fix the slot field accumulates correctly. + const string body = + "from pymcu.types import uint32\n\n" + + "class Ctr:\n" + + " def __init__(self, n: uint32, k: uint8):\n" + + " self.n = n\n" + + " self.k = k\n\n" + + " def bump(self, d: uint32):\n" + + " self.n = self.n + d\n\n" + + " def get(self) -> uint32:\n" + + " return self.n\n\n" + + "def run(s: uint8):\n" + + " c = Ctr(uint32(s) * 1000000, s)\n" + // 10_000_000 + " c.bump(2345678)\n" + // 12_345_678 + " print(c.get())\n" + + " print(c.n)\n" + + " print(c.k)\n"; // 10 + RunSeed(body, 10, 3).Should().Equal(12345678, 12345678, 10); + } + + [Test] + public void Int32SignedArithmetic() + { + const string body = + "from pymcu.types import int32\n\n" + + "def run(s: uint8):\n" + + " a: int32 = int32(0) - int32(s) * 1000000\n" + // -7000000 + " print(a)\n" + + " print(a // 3)\n" + // floor(-7000000/3) = -2333334 + " print(a % 3)\n" + // Python floor mod = 2 + " print(1 if a < 0 else 0)\n" + // 1 + " b: int32 = a + 7000005\n" + + " print(b)\n"; // 5 + var got = RunSeed(body, 7, 5); + TestContext.WriteLine("GOT=" + string.Join(",", got)); + got.Should().Equal(-7000000, -2333334, 2, 1, 5); + } + + [Test] + public void InstanceArrayMethodAndField() + { + const string body = + "from pymcu.types import uint16\n\n" + + "class Pt:\n" + + " def __init__(self, x: uint8, y: uint8):\n" + + " self.x = x\n" + + " self.y = y\n\n" + + " def sum(self) -> uint16:\n" + + " return uint16(self.x) + uint16(self.y)\n\n" + + "def run(s: uint8):\n" + + " pts: Pt[3] = [Pt(0, 0), Pt(0, 0), Pt(0, 0)]\n" + + " pts[0] = Pt(s, s + 1)\n" + + " pts[1] = Pt(s + 2, s + 3)\n" + + " pts[2] = Pt(s + 4, s + 5)\n" + + " print(pts[0].sum())\n" + // 11 + " print(pts[2].sum())\n" + // 19 + " i: uint8 = s - 4\n" + + " print(pts[i].sum())\n" + // pts[1] = 15 + " print(pts[1].x)\n" + // 7 + " pts[1].x = 99\n" + // direct field write on an element + " pts[i].y += 1\n" + // aug-assign field via runtime index (i=1) + " print(pts[1].x)\n" + // 99 + " print(pts[1].y)\n"; // 8+1 = 9 + RunSeed(body, 5, 6).Should().Equal(11, 19, 15, 7, 99, 9); + } + + [Test] + public void Uint32GlobalAndBytearrayParam() + { + const string body = + "from pymcu.types import uint32\n\n" + + "counter: uint32 = 0\n\n" + + "def add(n: uint32):\n" + + " global counter\n" + + " counter = counter + n\n\n" + + "def fill(b: bytearray, base: uint8) -> uint8:\n" + + " i: uint8 = 0\n" + + " while i < 4:\n" + + " b[i] = base + i\n" + + " i += 1\n" + + " return i\n\n" + + "def run(s: uint8):\n" + + " add(uint32(s) * 1000000)\n" + // 7000000 + " add(2345678)\n" + // 9345678 + " print(counter)\n" + + " buf: uint8[4] = [0, 0, 0, 0]\n" + + " n: uint8 = fill(buf, s)\n" + + " print(n)\n" + // 4 + " print(buf[0])\n" + // 7 + " print(buf[3])\n"; // 10 + var got = RunSeed(body, 7, 4); + TestContext.WriteLine("GOT=" + string.Join(",", got)); + got.Should().Equal(9345678, 4, 7, 10); + } + + [Test] + public void BytesIterTruthinessNegStep() + { + const string body = + "def run(s: uint8):\n" + + " total: uint8 = 0\n" + + " for b in b\"\\x01\\x02\\x03\\x04\":\n" + + " total += b\n" + + " print(total)\n" + // 10 + " x: uint8 = s\n" + + " print(1 if x else 0)\n" + // 1 (truthy) + " y: uint8 = 0\n" + + " print(1 if not y else 0)\n" + // 1 + " acc: uint8 = 0\n" + + " for i in range(s, 0, -1):\n" + // 3,2,1 + " acc += i\n" + + " print(acc)\n"; // 6 + var got = RunSeed(body, 3, 4); + TestContext.WriteLine("GOT=" + string.Join(",", got)); + got.Should().Equal(10, 1, 1, 6); + } + + [Test] + public void Uint16DivModRuntimeAndChainedCompare() + { + const string body = + "from pymcu.types import uint16\n\n" + + "def run(s: uint8):\n" + + " a: uint16 = uint16(s) * 1000\n" + // 7000 + " d: uint16 = uint16(s)\n" + // 7 + " print(a // d)\n" + // 1000 + " print(a % d)\n" + // 0 + " print(a // 13)\n" + // 538 + " print(a % 13)\n" + // 6 + " print(1 if 0 < a < 10000 else 0)\n" + // 1 + " print(1 if 7000 <= a < 7001 else 0)\n"; // 1 + var got = RunSeed(body, 7, 6); + TestContext.WriteLine("GOT=" + string.Join(",", got)); + got.Should().Equal(1000, 0, 538, 6, 1, 1); + } + + [Test] + public void NoMethodStruct_WideField() + { + // A multi-field struct with no methods uses the flattened-field path, which hard-coded + // uint8 and truncated a uint16 field (500 read back as 244). Standalone and as a Class[N] + // element (the array needs the contiguous slot layout even without methods). + const string body = + "from pymcu.types import uint16\n\n" + + "class Acc:\n" + + " def __init__(self, total: uint16, tag: uint8):\n" + + " self.total = total\n" + + " self.tag = tag\n\n" + + "def run(s: uint8):\n" + + " b = Acc(uint16(s) * 100, s + 1)\n" + + " print(b.total)\n" + // 500 + " b.total += 7\n" + + " print(b.total)\n" + // 507 + " accs: Acc[2] = [Acc(0, 0), Acc(0, 0)]\n" + + " accs[0] = Acc(uint16(s) * 100, s)\n" + // 500, 5 + " accs[1] = Acc(uint16(s) * 200, s + 1)\n" + // 1000, 6 + " i: uint8 = s - 5\n" + + " accs[i].total += 1234\n" + // accs[0] = 1734 + " print(accs[0].total)\n" + // 1734 + " print(accs[1].total)\n" + // 1000 + " print(accs[1].tag)\n"; // 6 + var got = RunSeed(body, 5, 5); + TestContext.WriteLine("GOT=" + string.Join(",", got)); + got.Should().Equal(500, 507, 1734, 1000, 6); + } + + [Test] + public void OperatorOverloadResultMethod() + { + // A65: an operator dunder returning a new slot-class instance, then a method call on the + // result. The result must be materialized into a slot so the method gets a self pointer + // (it used to pass the flattened fields, returning garbage: mag() gave 3 instead of 18). + const string body = + "from pymcu.types import uint16\n\n" + + "class Vec:\n" + + " def __init__(self, x: uint8, y: uint8):\n" + + " self.x = x\n" + + " self.y = y\n\n" + + " def __add__(self, other: Vec) -> Vec:\n" + + " return Vec(self.x + other.x, self.y + other.y)\n\n" + + " def mag(self) -> uint16:\n" + + " return uint16(self.x) + uint16(self.y)\n\n" + + "def run(s: uint8):\n" + + " a = Vec(s, s + 1)\n" + // (3,4) + " b = Vec(s + 2, s + 3)\n" + // (5,6) + " c = a + b\n" + // (8,10) + " print(c.x)\n" + // 8 + " print(c.y)\n" + // 10 + " print(c.mag())\n" + // 18 + " print(a.mag())\n" + // 7 + " print(b.mag())\n"; // 11 + var got = RunSeed(body, 3, 5); + TestContext.WriteLine("GOT=" + string.Join(",", got)); + got.Should().Equal(8, 10, 18, 7, 11); + } + + [Test] + public void InheritedFieldsInOverriddenMethod() + { + // A66: a subclass with no __init__ of its own inherits the base's fields; an overridden + // method must still resolve them. The subclass's field layout was empty, so `self.a` + // errored "not a member of a numeric value". Only inherited when the base is a slot class. + const string body = + "from pymcu.types import uint16\n\n" + + "class Base:\n" + + " def __init__(self, a: uint8, b: uint8):\n" + + " self.a = a\n" + + " self.b = b\n\n" + + " def combine(self) -> uint16:\n" + + " return uint16(self.a) + uint16(self.b)\n\n" + + "class Derived(Base):\n" + + " def combine(self) -> uint16:\n" + + " return uint16(self.a) * uint16(self.b)\n\n" + + "def run(s: uint8):\n" + + " base = Base(s, s + 1)\n" + + " der = Derived(s, s + 1)\n" + + " print(base.combine())\n" + // 13 + " print(der.combine())\n" + // 42 (overridden) + " print(der.a)\n"; // 6 (inherited field) + RunSeed(body, 6, 3).Should().Equal(13, 42, 6); + } + + [Test] + public void PropertyGetterSetter() + { + const string body = + "from pymcu.types import uint16\n\n" + + "class Temp:\n" + + " def __init__(self, raw: uint8):\n" + + " self._raw = raw\n\n" + + " @property\n" + + " def celsius(self) -> uint16:\n" + + " return uint16(self._raw) * 2\n\n" + + " @celsius.setter\n" + + " def celsius(self, v: uint8):\n" + + " self._raw = v // 2\n\n" + + "def run(s: uint8):\n" + + " t = Temp(s)\n" + // _raw=10 + " print(t.celsius)\n" + // 20 (getter) + " t.celsius = 40\n" + // setter -> _raw=20 + " print(t.celsius)\n" + // 40 + " print(t._raw)\n"; // 20 + var got = RunSeed(body, 10, 3); + TestContext.WriteLine("GOT=" + string.Join(",", got)); + got.Should().Equal(20, 40, 20); + } + + [Test] + public void TernaryWideCallBranch() + { + // A ternary branch that is a uint16-returning call: the result must stay 16-bit. + // s=5: big() if s>3 -> 500; 7 if s>3 -> 7 (the wide call is on the not-taken side). + const string body = + "from pymcu.types import uint16\n\n" + + "def big() -> uint16:\n" + + " return 500\n\n" + + "def run(s: uint8):\n" + + " print(big() if s > 3 else 7)\n" + // 500 + " print(7 if s > 3 else big())\n"; // 7 + RunSeed(body, 5, 2).Should().Equal(500, 7); + } + + [Test] + public void ArgEvalOrder_LeftToRight() + { + // tick() returns 1,2,3,... on successive calls. Python evaluates arguments and + // binary operands left-to-right, so diff(tick(), tick()) = diff(1,2) = -1 and + // tick()*10 + tick() = 3*10 + 4 = 34. Right-to-left eval would give +1 and 43. + const string body = + "from pymcu.types import int16\n\n" + + "_n: uint8 = 0\n\n" + + "def tick() -> uint8:\n" + + " global _n\n" + + " _n += 1\n" + + " return _n\n\n" + + "def diff(a: uint8, b: uint8) -> int16:\n" + + " return int16(a) - int16(b)\n\n" + + "def run(s: uint8):\n" + + " print(diff(tick(), tick()))\n" + // diff(1,2) = -1 + " print(tick() * 10 + tick())\n"; // 3*10 + 4 = 34 + RunSeed(body, 5, 2).Should().Equal(-1, 34); + } + + // ── @property stress battery ─────────────────────────────────────────────── + + [Test] + public void Property_InExpressionConditionAndLoop() + { + // getter recomputed each access; used in arithmetic, a loop accumulator, and a condition. + const string body = + "from pymcu.types import uint16\n\n" + + "class Temp:\n" + + " def __init__(self, raw: uint8):\n" + + " self._raw = raw\n\n" + + " @property\n" + + " def celsius(self) -> uint16:\n" + + " return uint16(self._raw) * 2\n\n" + + "def run(s: uint8):\n" + + " t = Temp(s)\n" + // raw=5 -> celsius=10 + " print(t.celsius)\n" + // 10 + " total: uint16 = 0\n" + + " i: uint8 = 0\n" + + " while i < 3:\n" + + " total += t.celsius\n" + // 10*3 + " i += 1\n" + + " print(total)\n" + // 30 + " print(t.celsius + 100)\n" + // 110 + " print(1 if t.celsius > 5 else 0)\n"; // 1 + RunSeed(body, 5, 4).Should().Equal(10, 30, 110, 1); + } + + [Test] + public void Property_AugAssignThroughGetterSetter() + { + // b.val += 5 must read via the getter and write via the setter. + const string body = + "class Box:\n" + + " def __init__(self, v: uint8):\n" + + " self._v = v\n\n" + + " @property\n" + + " def val(self) -> uint8:\n" + + " return self._v\n\n" + + " @val.setter\n" + + " def val(self, x: uint8):\n" + + " self._v = x\n\n" + + "def run(s: uint8):\n" + + " b = Box(s)\n" + + " b.val = 20\n" + + " print(b.val)\n" + // 20 + " b.val += 5\n" + + " print(b.val)\n"; // 25 + RunSeed(body, 5, 2).Should().Equal(20, 25); + } + + [Test] + public void Property_MultiFieldComputedWide() + { + // getter over TWO fields, producing a 16-bit product. + const string body = + "from pymcu.types import uint16\n\n" + + "class Rect:\n" + + " def __init__(self, w: uint8, h: uint8):\n" + + " self._w = w\n" + + " self._h = h\n\n" + + " @property\n" + + " def area(self) -> uint16:\n" + + " return uint16(self._w) * uint16(self._h)\n\n" + + "def run(s: uint8):\n" + + " r = Rect(s, 30)\n" + // 5*30 + " print(r.area)\n"; // 150 + RunSeed(body, 5, 1).Should().Equal(150); + } + + [Test] + public void Property_GetterCallsMethod() + { + // getter body invokes another method on self. + const string body = + "from pymcu.types import uint16\n\n" + + "class Calc:\n" + + " def __init__(self, base: uint8):\n" + + " self._base = base\n\n" + + " def doubled(self) -> uint16:\n" + + " return uint16(self._base) * 2\n\n" + + " @property\n" + + " def result(self) -> uint16:\n" + + " return self.doubled() + 1\n\n" + + "def run(s: uint8):\n" + + " c = Calc(s)\n" + // base=5 + " print(c.result)\n"; // 5*2+1 = 11 + RunSeed(body, 5, 1).Should().Equal(11); + } + + [Test] + public void Property_MultipleAndMutatingFields() + { + // no-arg __init__, two fields, a branching getter, methods that mutate fields. + const string body = + "from pymcu.types import uint16\n\n" + + "class Acc:\n" + + " def __init__(self):\n" + + " self._sum = 0\n" + + " self._count = 0\n\n" + + " def add(self, v: uint8):\n" + + " self._sum += v\n" + + " self._count += 1\n\n" + + " @property\n" + + " def average(self) -> uint16:\n" + + " if self._count == 0:\n" + + " return 0\n" + + " return self._sum // self._count\n\n" + + "def run(s: uint8):\n" + + " a = Acc()\n" + + " print(a.average)\n" + // 0 (count==0) + " a.add(s)\n" + // sum=5,count=1 + " a.add(10)\n" + // sum=15,count=2 + " a.add(30)\n" + // sum=45,count=3 + " print(a.average)\n"; // 15 + RunSeed(body, 5, 2).Should().Equal(0, 15); + } + + [Test] + public void Property_AsArgAndIndex() + { + // property result used as a function argument and as an array index. + const string body = + "from pymcu.types import uint8\n\n" + + "class P:\n" + + " def __init__(self, k: uint8):\n" + + " self._k = k\n\n" + + " @property\n" + + " def idx(self) -> uint8:\n" + + " return self._k + 1\n\n" + + "def twice(x: uint8) -> uint8:\n" + + " return x * 2\n\n" + + "def run(s: uint8):\n" + + " p = P(s)\n" + // k=2 -> idx=3 + " arr: uint8[5]\n" + + " arr[0] = 11\n" + + " arr[1] = 22\n" + + " arr[2] = 33\n" + + " arr[3] = 44\n" + + " print(twice(p.idx))\n" + // twice(3)=6 + " print(arr[p.idx])\n"; // arr[3]=44 + RunSeed(body, 2, 2).Should().Equal(6, 44); + } + + [Test] + public void Property_SetterClampsAndChains() + { + // setter runs logic (clamp); multiple set/get cycles. + const string body = + "from pymcu.types import uint8\n\n" + + "class Dimmer:\n" + + " def __init__(self):\n" + + " self._level = 0\n\n" + + " @property\n" + + " def level(self) -> uint8:\n" + + " return self._level\n\n" + + " @level.setter\n" + + " def level(self, v: uint8):\n" + + " self._level = v if v < 100 else 100\n\n" + + "def run(s: uint8):\n" + + " d = Dimmer()\n" + + " d.level = s\n" + + " print(d.level)\n" + // 5 + " d.level = 200\n" + // clamped to 100 + " print(d.level)\n" + // 100 + " d.level = 42\n" + + " print(d.level)\n"; // 42 + RunSeed(body, 5, 3).Should().Equal(5, 100, 42); + } + + // ── dunder / inheritance / vtable battery ────────────────────────────────── + + [Test] + public void Dunder_ComparisonOperators() + { + // __lt__, __le__, __gt__, __eq__ used in conditions and printed directly. + const string body = + "from pymcu.types import uint16\n\n" + + "class Money:\n" + + " def __init__(self, c: uint16):\n" + + " self._c = c\n\n" + + " def __lt__(self, o: Money) -> bool:\n" + + " return self._c < o._c\n\n" + + " def __eq__(self, o: Money) -> bool:\n" + + " return self._c == o._c\n\n" + + "def run(s: uint8):\n" + + " a = Money(uint16(s) * 100)\n" + // 500 + " b = Money(300)\n" + + " print(1 if a < b else 0)\n" + // 0 + " print(1 if b < a else 0)\n" + // 1 + " print(1 if a == b else 0)\n" + // 0 + " print(1 if a == a else 0)\n"; // 1 + RunSeed(body, 5, 4).Should().Equal(0, 1, 0, 1); + } + + [Test] + public void Dunder_GetSetItemAndLen() + { + // custom __getitem__, __setitem__ and __len__ on a wrapper around a fixed array. + const string body = + "from pymcu.types import uint8\n\n" + + "class Buf:\n" + + " def __init__(self):\n" + + " self._data: uint8[4]\n" + + " self._n = 4\n\n" + + " def __getitem__(self, i: uint8) -> uint8:\n" + + " return self._data[i]\n\n" + + " def __setitem__(self, i: uint8, v: uint8):\n" + + " self._data[i] = v\n\n" + + " def __len__(self) -> uint8:\n" + + " return self._n\n\n" + + "def run(s: uint8):\n" + + " b = Buf()\n" + + " b[0] = s\n" + + " b[1] = s + 10\n" + + " print(b[0])\n" + // 5 + " print(b[1])\n" + // 15 + " print(len(b))\n"; // 4 + RunSeed(body, 5, 3).Should().Equal(5, 15, 4); + } + + [Test] + public void Inherit_SuperInitAndOverride() + { + // super().__init__() sets an inherited field; the override reads both fields. + const string body = + "from pymcu.types import uint16\n\n" + + "class Animal:\n" + + " def __init__(self, legs: uint8):\n" + + " self._legs = legs\n\n" + + " def describe(self) -> uint16:\n" + + " return self._legs\n\n" + + "class Dog(Animal):\n" + + " def __init__(self, name: uint8):\n" + + " super().__init__(4)\n" + + " self._name = name\n\n" + + " def describe(self) -> uint16:\n" + + " return uint16(self._legs) * 10 + self._name\n\n" + + "def run(s: uint8):\n" + + " d = Dog(s)\n" + // name=5, legs=4 + " print(d.describe())\n"; // 4*10+5 = 45 + RunSeed(body, 5, 1).Should().Equal(45); + } + + [Test] + public void Inherit_TemplateMethodDispatch() + { + // The vtable test: Shape.total() calls self.unit(); Square overrides unit(). Calling + // total() on a Square must dispatch to Square.unit() (4), not Shape.unit() (1). + const string body = + "from pymcu.types import uint16\n\n" + + "class Shape:\n" + + " def __init__(self, n: uint8):\n" + + " self._n = n\n\n" + + " def unit(self) -> uint16:\n" + + " return 1\n\n" + + " def total(self) -> uint16:\n" + + " return uint16(self._n) * self.unit()\n\n" + + "class Square(Shape):\n" + + " def unit(self) -> uint16:\n" + + " return 4\n\n" + + "def run(s: uint8):\n" + + " sq = Square(s)\n" + // n=5 + " print(sq.total())\n" + // 5*4 = 20 (Square.unit) + " sh = Shape(s)\n" + + " print(sh.total())\n"; // 5*1 = 5 (Shape.unit) + RunSeed(body, 5, 2).Should().Equal(20, 5); + } + + [Test] + public void Inherit_MultiLevelResolution() + { + // A -> B -> C. C inherits __init__ from A and kind() from B (overriding A.kind()). + const string body = + "from pymcu.types import uint8\n\n" + + "class A:\n" + + " def __init__(self, v: uint8):\n" + + " self._v = v\n\n" + + " def kind(self) -> uint8:\n" + + " return 1\n\n" + + "class B(A):\n" + + " def kind(self) -> uint8:\n" + + " return 2\n\n" + + "class C(B):\n" + + " def bump(self) -> uint8:\n" + + " return self.kind() + 10\n\n" + + "def run(s: uint8):\n" + + " c = C(s)\n" + + " print(c.kind())\n" + // 2 (from B) + " print(c._v)\n" + // 5 (init from A) + " print(c.bump())\n"; // 2 + 10 = 12 (self.kind() -> B.kind) + RunSeed(body, 5, 3).Should().Equal(2, 5, 12); + } + + [Test] + public void Inherit_OverrideCallsSuperMethod() + { + // A subclass method overriding a base method while still invoking the base via super(). + // The base is a 2-field slot class so construction goes through the (working) slot path. + const string body = + "from pymcu.types import uint16\n\n" + + "class Base:\n" + + " def __init__(self, v: uint8, w: uint8):\n" + + " self._v = v\n" + + " self._w = w\n\n" + + " def score(self) -> uint16:\n" + + " return uint16(self._v) * 2 + self._w\n\n" + + "class Boosted(Base):\n" + + " def __init__(self, v: uint8):\n" + + " super().__init__(v, 3)\n\n" + + " def score(self) -> uint16:\n" + + " return super().score() + 100\n\n" + + "def run(s: uint8):\n" + + " x = Boosted(s)\n" + // v=5, w=3 + " print(x.score())\n"; // (5*2 + 3) + 100 = 113 + RunSeed(body, 5, 1).Should().Equal(113); + } + + [Test] + public void AbsMinMax_WideValues() + { + // s=5: y=500, x=-500 -> abs=500; max(y,50)=500, min(y,50)=50. + // abs/min/max used a bare uint8 result temp that truncated wide values (500 -> 244). + // (y is materialized first so the multiply widens to its int16 target -- the fixed-width + // intermediate `0 - s*100` is a separate, intentional wrap and not what this probes.) + const string body = + "from pymcu.types import int16\n\n" + + "def run(s: uint8):\n" + + " y: int16 = s * 100\n" + + " x: int16 = 0 - y\n" + + " print(x)\n" + // -500 + " print(abs(x))\n" + // 500 + " print(max(y, 50))\n" + // 500 + " print(min(y, 50))\n"; // 50 + RunSeed(body, 5, 4).Should().Equal(-500, 500, 500, 50); + } + + [Test] + public void IndirectCallWideReturn() + { + // s=5: big(5)=500 (uint16) called through a Callable. A uint8 result temp at the + // indirect call site would read only the low byte -> 244. + const string body = + "from pymcu.types import uint16, Callable\n\n" + + "def big(x: uint8) -> uint16:\n" + + " return uint16(x) * 100\n\n" + + "def run(s: uint8):\n" + + " f: Callable = big\n" + + " print(f(s))\n"; // 500 + RunSeed(body, 5, 1).Should().Equal(500); + } + + [Test] + public void SumOfWideElements() + { + // s=5: a=500, b=300 (both uint16). sum([a,b]) = 800. A uint8 accumulator temp + // truncates the already-wide operands to 800 & 0xFF = 32. + // (All-uint8 operands wrapping is consistent fixed-width behavior and not probed here.) + const string body = + "from pymcu.types import uint16\n\n" + + "def run(s: uint8):\n" + + " a: uint16 = s * 100\n" + + " b: uint16 = 300\n" + + " print(sum([a, b]))\n"; // 800 + RunSeed(body, 5, 1).Should().Equal(800); + } + + [Test] + public void TernaryMixedWidthBranches() + { + // s=5: cond false -> picks the uint16 branch (500). If the result temp is typed + // only from the true branch (uint8 const 7), the false branch 500 truncates -> 244. + const string body = + "from pymcu.types import uint16\n\n" + + "def run(s: uint8):\n" + + " a: uint16 = 500\n" + + " print(7 if s > 100 else a)\n" + // 500 + " print(a if s > 100 else 7)\n"; // 7 + RunSeed(body, 5, 2).Should().Equal(500, 7); + } + + [Test] + public void AugAssignChain_Uint16() + { + // s=5: x=5; *=100 ->500; -=50 ->450; //=7 ->64; %=10 ->4 + const string body = + "from pymcu.types import uint16\n\n" + + "def run(s: uint8):\n" + + " x: uint16 = s\n" + + " x *= 100\n" + + " print(x)\n" + // 500 + " x -= 50\n" + + " print(x)\n" + // 450 + " x //= 7\n" + + " print(x)\n" + // 64 + " x %= 10\n" + + " print(x)\n"; // 4 + RunSeed(body, 5, 4).Should().Equal(500, 450, 64, 4); + } + + [Test] + public void OperatorPrecedence() + { + const string body = + "def run(s: uint8):\n" + + " print(2 + 3 * 4 - 10 // 2)\n" + // 9 + " print(1 << 3 | 2 & 3)\n" + // 8 | (2&3) = 10 + " print(1 if (s > 5 and s < 10) or s == 0 else 0)\n"; // 1 + RunSeed(body, 7, 3).Should().Equal(9, 10, 1); + } + + [Test] + public void NestedTernary() + { + // s=5 -> middle branch. classify: <3 ->100, <7 ->200, else 300 + const string body = + "def run(s: uint8):\n" + + " r: uint8 = 100 if s < 3 else (200 if s < 7 else 250)\n" + + " print(r)\n"; // 200 + RunSeed(body, 5, 1).Should().Equal(200); + } + + [Test] + public void OrAndValueSemantics() + { + // Python `or`/`and` return an OPERAND, not a 0/1 bool. + const string body = + "def run(s: uint8):\n" + + " print(s or 99)\n" + // 5 truthy -> 5 + " print((s - s) or 99)\n" + // 0 -> 99 + " print(s and 7)\n" + // 5 truthy -> 7 + " print((s - s) and 7)\n"; // 0 -> 0 + RunSeed(body, 5, 4).Should().Equal(5, 99, 7, 0); + } + + [Test] + public void BitNotUint8() + { + // Python ~5 == -6 (infinite precision). On a fixed-width uint8 the faithful-ish + // result is the 8-bit complement 250; check what PyMCU actually emits. + const string body = + "def run(s: uint8):\n" + + " print(~s)\n"; + RunSeed(body, 5, 1).Should().Equal(250); + } + + [Test] + public void AugAssignWrapOnStore() + { + // x is uint8 storage: 200 + 100 = 300 promotes, then truncates back to uint8 = 44. + const string body = + "def run(s: uint8):\n" + + " x: uint8 = 200\n" + + " x = x + s\n" + // s=100 -> 300 -> store uint8 -> 44 + " print(x)\n"; + RunSeed(body, 100, 1).Should().Equal(44); + } + + [Test] + public void RShiftSignedIsArithmetic() + { + // Python >> on a negative int is an arithmetic shift (floors): -8 >> 1 == -4. + const string body = + "from pymcu.types import int16\n\n" + + "def run(s: uint8):\n" + + " n: int16 = 0 - s\n" + // -8 + " print(n >> 1)\n"; // -4 (arithmetic, not 32764 logical) + RunSeed(body, 8, 1).Should().Equal(-4); + } + + [Test] + public void RangeNegativeStep() + { + const string body = + "def run(s: uint8):\n" + + " acc: uint8 = 0\n" + + " for i in range(s, 0, -1):\n" + // 5,4,3,2,1 + " acc = acc + i\n" + + " print(acc)\n"; // 15 + RunSeed(body, 5, 1).Should().Equal(15); + } + + [Test] + public void RangeStepTwo() + { + const string body = + "def run(s: uint8):\n" + + " acc: uint8 = 0\n" + + " for i in range(0, s, 2):\n" + // 0,2,4,6,8 + " acc = acc + i\n" + + " print(acc)\n"; // 20 + RunSeed(body, 10, 1).Should().Equal(20); + } + + [Test] + public void InlineClosureCapturesEnclosingVar() + { + // A nested @inline function reads a variable from the enclosing scope; the capture must + // resolve to the caller's value (regression: it silently read 0, so add(10) gave 10). + const string body = + "from pymcu.types import inline\n\n" + + "def run(s: uint8):\n" + + " base: uint8 = s\n" + + " @inline\n" + + " def add(x: uint8) -> uint8:\n" + + " return x + base\n" + + " print(add(10))\n"; // 5 + 10 = 15 + RunSeed(body, 5, 1).Should().Equal(15); + } + + [Test] + public void SliceToInferredArray() + { + // `b = a[lo:hi]` without an array annotation infers b as a fixed-size array and copies + // the slice's elements; b[i] must read the sliced values (regression: it read 0). + const string body = + "def run(s: uint8):\n" + + " a: uint8[4] = [10, 20, 30, 40]\n" + + " a[0] = s\n" + + " b = a[1:3]\n" + + " print(b[0])\n" + // 20 + " print(b[1])\n"; // 30 + RunSeed(body, 5, 2).Should().Equal(20, 30); + } + + [Test] + public void InlineClosureNestedCapture() + { + // Two-level closure: inner() captures `base` from the plain function run AND `bonus` from + // the enclosing @inline outer. Both captures must resolve (regression: bonus read 0 -> 15). + const string body = + "from pymcu.types import inline\n\n" + + "def run(s: uint8):\n" + + " base: uint8 = s\n" + // 5 + " @inline\n" + + " def outer(x: uint8) -> uint8:\n" + + " bonus: uint8 = 100\n" + + " @inline\n" + + " def inner(y: uint8) -> uint8:\n" + + " return y + base + bonus\n" + // capture base (run) + bonus (outer) + " return inner(x)\n" + + " print(outer(10))\n"; // 10 + 5 + 100 = 115 + RunSeed(body, 5, 1).Should().Equal(115); + } + + [Test] + public void InlineClosureNonlocalRebind() + { + // `nonlocal` rebinds the enclosing variable: Python prints 42. + const string body = + "from pymcu.types import inline\n\n" + + "def run(s: uint8):\n" + + " base: uint8 = s\n" + + " @inline\n" + + " def setit():\n" + + " nonlocal base\n" + + " base = 42\n" + + " setit()\n" + + " print(base)\n"; // 42 + RunSeed(body, 5, 1).Should().Equal(42); + } + + [Test] + public void InlineClosureWriteMakesLocal() + { + // An inline that declares its OWN local shadowing a captured name must not clobber the + // enclosing variable (Python: assignment without nonlocal makes a local). + const string body = + "from pymcu.types import inline\n\n" + + "def run(s: uint8):\n" + + " base: uint8 = s\n" + // 5 + " @inline\n" + + " def f(x: uint8) -> uint8:\n" + + " base: uint8 = x + 1\n" + // NEW local + " return base\n" + + " print(f(10))\n" + // 11 + " print(base)\n"; // 5 (unchanged) + RunSeed(body, 5, 2).Should().Equal(11, 5); + } + + [Test] + public void InlineClosureCaptureByReference() + { + // Closures capture by reference: reassigning the enclosing var before the call must be + // visible inside (Python prints 99, not 5). + const string body = + "from pymcu.types import inline\n\n" + + "def run(s: uint8):\n" + + " base: uint8 = s\n" + // 5 + " @inline\n" + + " def f() -> uint8:\n" + + " return base\n" + + " base = 99\n" + + " print(f())\n"; // 99 + RunSeed(body, 5, 1).Should().Equal(99); + } + + [Test] + public void FStringToStreamFloatAndNegative() + { + const string src = + "from pymcu.types import uint8, int16\n" + + "from pymcu.hal.uart import UART\n\n\n" + + "def main():\n" + + " uart = UART(9600)\n" + + " uart.println(\"GO\")\n" + + " s: uint8 = uart.read_blocking()\n" + + " neg: int16 = 0 - int16(s)\n" + + " f: float = float(s) / 2.0\n" + + " print(f\"neg={neg} f={f}!\")\n" + + " while True:\n pass\n"; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(5); + uno.RunUntilSerial(uno.Serial, t => t.Contains("!"), maxMs: 6000); + uno.Serial.Text.Should().Contain("neg=-5 f=2.5!"); + } + + [Test] + public void FStringConstantInterpolation() + { + const string src = + "from pymcu.types import uint8\n" + + "from pymcu.hal.uart import UART\n\n\n" + + "NAME: const[str] = \"PB5\"\n" + + "def main():\n" + + " uart = UART(9600)\n" + + " uart.println(\"GO\")\n" + + " s: uint8 = uart.read_blocking()\n" + + " print(f\"pin {NAME} = {s}\")\n" + + " while True:\n pass\n"; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(7); + uno.RunUntilSerial(uno.Serial, t => t.Contains("= 7"), maxMs: 6000); + uno.Serial.Text.Should().Contain("pin PB5 = 7"); + } + + [Test] + public void FStringToStream() + { + const string src = + "from pymcu.types import uint8, uint16\n" + + "from pymcu.hal.uart import UART\n\n\n" + + "def main():\n" + + " uart = UART(9600)\n" + + " uart.println(\"GO\")\n" + + " s: uint8 = uart.read_blocking()\n" + + " n: uint16 = uint16(s) * 100\n" + + " print(f\"v={s} n={n}!\")\n" + + " while True:\n pass\n"; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(5); + uno.RunUntilSerial(uno.Serial, t => t.Contains("!"), maxMs: 6000); + uno.Serial.Text.Should().Contain("v=5 n=500!"); + } + + [Test] + public void SpilledArgFromNestedCall() + { + // A spilled argument whose value comes from a nested call: the call result must survive + // into the spill region. Regression: register-arg loads ran first and clobbered the temp + // holding the call result, so the spilled arg read 0 (fixed by storing spilled args first). + const string body = + "def add1(x: uint8) -> uint8:\n return x + 1\n" + + "def f6(a: uint8, b: uint8, c: uint8, d: uint8, e: uint8, g: uint8) -> uint8:\n" + + " return a + b + c + d + e + g * 10\n" + + "def run(s: uint8):\n" + + " print(f6(1, 1, 1, 1, 1, add1(s)))\n"; // g=add1(5)=6 -> 60; +5 = 65 + RunSeed(body, 5, 1).Should().Equal(65); + } + + [Test] + public void DefaultArgViaSpill() + { + // A default value that fills a spilled parameter must be stored to the spill region. + const string body = + "def f6(a: uint8, b: uint8, c: uint8, d: uint8, e: uint8, g: uint8 = 7) -> uint8:\n" + + " return a + b + c + d + e + g * 10\n" + + "def run(s: uint8):\n" + + " print(f6(1, 1, 1, 1, 1))\n" + // g default 7 -> 70+5 = 75 + " print(f6(s, 0, 0, 0, 0, 3))\n"; // g=3 -> 30+5 = 35 + RunSeed(body, 5, 2).Should().Equal(75, 35); + } + + [Test] + public void MethodSelfPlusFiveArgsViaSpill() + { + // A method passes self as arg0; self + 5 user args = 6 → the 6th spills. + const string body = + "from pymcu.types import uint16\n\n" + + "class Calc:\n" + + " def __init__(self, base: uint8):\n self._base = base\n" + + " def combine(self, a: uint8, b: uint8, c: uint8, d: uint8, e: uint8) -> uint16:\n" + + " return uint16(self._base) + a + b * 2 + c * 4 + d * 8 + e * 16\n" + + "def run(s: uint8):\n" + + " c = Calc(s)\n" + + " print(c.combine(1, 1, 1, 1, 1))\n"; // 5 + 1+2+4+8+16 = 36 + RunSeed(body, 5, 1).Should().Equal(36); + } + + [Test] + public void ConsecutiveSpillCalls() + { + // Two consecutive calls reuse the shared spill region; each must be independent. + const string body = + "def f6(a: uint8, b: uint8, c: uint8, d: uint8, e: uint8, g: uint8) -> uint16:\n" + + " return uint16(a) + b + c + d + e + g * 10\n" + + "def run(s: uint8):\n" + + " x: uint16 = f6(1, 1, 1, 1, 1, s)\n" + // 5 + s*10 + " y: uint16 = f6(2, 2, 2, 2, 2, s)\n" + // 10 + s*10 + " print(x)\n" + // s=3: 35 + " print(y)\n"; // 40 + RunSeed(body, 3, 2).Should().Equal(35, 40); + } + + [Test] + public void BitNotUint16Width() + { + const string body = + "from pymcu.types import uint16\n\n" + + "def run(s: uint8):\n" + + " a: uint16 = s\n" + // 5 + " print(~a)\n"; // 16-bit complement: 0xFFFA = 65530 + RunSeed(body, 5, 1).Should().Equal(65530); + } + + [Test] + public void Uint32RuntimeDivMod() + { + const string body = + "from pymcu.types import uint32\n\n" + + "def run(s: uint8):\n" + + " a: uint32 = 1000000\n" + + " d: uint32 = uint32(s)\n" + // runtime divisor = 7 + " print(a % d)\n" + // 1000000 % 7 = 1 + " print(a // d)\n"; // 142857 + RunSeed(body, 7, 2).Should().Equal(1, 142857); + } + + [Test] + public void GlobalArrayMutationPersists() + { + const string body = + "counts: uint8[4] = [0, 0, 0, 0]\n\n" + + "def bump(i: uint8):\n" + + " counts[i] = counts[i] + 1\n" + + "def run(s: uint8):\n" + + " bump(1)\n" + + " bump(1)\n" + + " bump(s)\n" + // s=2 + " print(counts[1])\n" + // 2 + " print(counts[2])\n"; // 1 + RunSeed(body, 2, 2).Should().Equal(2, 1); + } + + [Test] + public void TryAroundValueContextDivision() + { + // Regression: a value-context division inside a try (no Call in the body, so VisitTry adds + // no BranchOnError) reached its catch dispatcher only via the div-zero guard's SignalError. + // The optimizer did not count SignalError.CatchLabel as a CFG edge, deleted the catch block + // and left a dangling jump -> link failure (undefined label). Must build and catch (88). + const string body = + "def run(s: uint8):\n" + + " r: uint8 = 1\n" + + " try:\n" + + " r = 100 // s\n" + // s=0 -> ZeroDivisionError; value-context, no Call in try + " except ZeroDivisionError:\n" + + " r = 88\n" + + " print(r)\n"; + RunSeed(body, 0, 1).Should().Equal(88); + } + + [Test] + public void ExceptionPropagatesThroughLevels() + { + // An error raised deep (c) and uncaught in the intermediate frame (bb) must propagate up + // through every CanFail frame to the try in run(). + const string body = + "def c(b: uint8) -> uint8:\n return 100 // b\n" + + "def bb(b: uint8) -> uint8:\n return c(b) + 1\n" + + "def run(s: uint8):\n" + + " try:\n" + + " print(bb(s))\n" + // s=0 -> ZeroDivisionError propagates c->bb->run + " except ZeroDivisionError:\n" + + " print(55)\n"; + RunSeed(body, 0, 1).Should().Equal(55); + } + + [Test] + public void WrongTypeExceptionPropagatesToOuter() + { + const string body = + "def run(s: uint8):\n" + + " try:\n" + + " try:\n" + + " print(100 // s)\n" + // ZeroDivisionError + " except IndexError:\n" + // wrong type -> does not catch + " print(1)\n" + + " except ZeroDivisionError:\n" + // outer catches + " print(66)\n"; + RunSeed(body, 0, 1).Should().Equal(66); + } + + [Test] + public void FinallyRunsOnErrorPath() + { + const string body = + "def run(s: uint8):\n" + + " try:\n" + + " print(100 // s)\n" + // s=0 -> ZeroDivisionError + " except ZeroDivisionError:\n" + + " print(7)\n" + + " finally:\n" + + " print(9)\n"; + RunSeed(body, 0, 2).Should().Equal(7, 9); // except runs, then finally + } + + [Test] + public void RaiseFromCalleeCaught() + { + const string body = + "def mayfail(s: uint8) -> uint8:\n" + + " if s == 0:\n raise ValueError\n" + + " return s * 2\n" + + "def run(s: uint8):\n" + + " try:\n" + + " print(mayfail(s))\n" + // s=0 -> ValueError propagates -> caught + " except ValueError:\n" + + " print(3)\n"; + RunSeed(body, 0, 1).Should().Equal(3); + } + + [Test] + public void TryElseRunsWhenNoException() + { + const string body = + "def run(s: uint8):\n" + + " try:\n" + + " y: uint8 = 100 // s\n" + // s=5 -> ok, no exception + " except ZeroDivisionError:\n" + + " print(1)\n" + + " else:\n" + + " print(99)\n"; // runs only if no exception + RunSeed(body, 5, 1).Should().Equal(99); + } + + [Test] + public void TryElseSkippedOnException() + { + const string body = + "def run(s: uint8):\n" + + " try:\n" + + " print(100 // s)\n" + // s=0 -> exception + " except ZeroDivisionError:\n" + + " print(5)\n" + + " else:\n" + + " print(99)\n"; // must NOT run when an exception occurred + RunSeed(body, 0, 1).Should().Equal(5); + } + + [Test] + public void ReraiseInExceptPropagates() + { + const string body = + "def inner(s: uint8) -> uint8:\n" + + " try:\n" + + " return 100 // s\n" + + " except ZeroDivisionError:\n" + + " raise ValueError\n" + // re-raise as a different type + "def run(s: uint8):\n" + + " try:\n" + + " print(inner(s))\n" + + " except ValueError:\n" + + " print(42)\n"; + RunSeed(body, 0, 1).Should().Equal(42); + } + + [Test] + public void SecondExceptClauseMatches() + { + const string body = + "def run(s: uint8):\n" + + " try:\n" + + " print(100 // s)\n" + + " except IndexError:\n" + // no match + " print(1)\n" + + " except ZeroDivisionError:\n" + // matches + " print(2)\n"; + RunSeed(body, 0, 1).Should().Equal(2); + } + + [Test] + public void FinallyRunsBeforeBreak() + { + const string body = + "def run(s: uint8):\n" + + " total: uint8 = 0\n" + + " for i in range(s):\n" + + " try:\n" + + " if i == 2:\n break\n" + + " total = total + i\n" + + " finally:\n" + + " total = total + 10\n" + + " print(total)\n"; + // i=0: +0,+10=10 ; i=1: +1,+10=21 ; i=2: break but finally +10 -> 31 + RunSeed(body, 5, 1).Should().Equal(31); + } + + [Test] + public void FinallyRunsBeforeContinue() + { + const string body = + "def run(s: uint8):\n" + + " total: uint8 = 0\n" + + " for i in range(s):\n" + + " try:\n" + + " if i == 2:\n continue\n" + + " total = total + i\n" + + " finally:\n" + + " total = total + 10\n" + + " print(total)\n"; + // each iter finally +10 (5x=50); body adds 0+1+3+4=8 (i=2 skipped) -> 58 + RunSeed(body, 5, 1).Should().Equal(58); + } + + [Test] + public void ErrorCodePreservedThroughFinally() + { + // The error code lives in R22 while propagating. A finally that runs work (a print, which + // calls a uart routine) between the raise and the propagation must not clobber it, or the + // wrong handler is selected. + const string body = + "def f(s: uint8) -> uint8:\n" + + " try:\n" + + " return 100 // s\n" + // s=0 -> ZeroDivisionError (code 6) + " finally:\n" + + " print(8)\n" + // runs a uart routine: must preserve R22 + "def run(s: uint8):\n" + + " try:\n" + + " print(f(s))\n" + + " except ValueError:\n" + // wrong type + " print(1)\n" + + " except ZeroDivisionError:\n" + // correct + " print(2)\n"; + RunSeed(body, 0, 2).Should().Equal(8, 2); + } + + [Test] + public void RaiseInElsePropagates() + { + // A raise in the else block is NOT caught by this try (Python); an outer try catches it. + const string body = + "def run(s: uint8):\n" + + " try:\n" + + " try:\n" + + " y: uint8 = 100 // s\n" + // s=5 -> ok, else runs + " except ZeroDivisionError:\n" + + " print(1)\n" + + " else:\n" + + " raise ValueError\n" + // not caught by inner; propagates + " except ValueError:\n" + + " print(9)\n"; + RunSeed(body, 5, 1).Should().Equal(9); + } + + [Test] + public void RaiseInFinallyOverrides() + { + const string body = + "def f(s: uint8) -> uint8:\n" + + " try:\n" + + " return s\n" + // no exception in body + " finally:\n" + + " if s == 0:\n raise ValueError\n" + // finally raises + "def run(s: uint8):\n" + + " try:\n" + + " print(f(s))\n" + + " except ValueError:\n" + + " print(7)\n"; + RunSeed(body, 0, 1).Should().Equal(7); + } + + [Test] + public void RecoverAndContinueAfterTry() + { + const string body = + "def run(s: uint8):\n" + + " try:\n" + + " print(100 // s)\n" + // s=0 -> caught + " except ZeroDivisionError:\n" + + " print(1)\n" + + " print(99)\n"; // runs after the try regardless + RunSeed(body, 0, 2).Should().Equal(1, 99); + } + + [Test] + public void TryInLoopRecoversEachIteration() + { + // try/except inside a loop: the T-flag must be clean each iteration so a caught error in + // one iteration does not leak into the next. + const string body = + "def run(s: uint8):\n" + + " total: uint8 = 0\n" + + " for i in range(s):\n" + + " try:\n" + + " total = total + 100 // i\n" + // i=0 raises; i=1,2,3 -> 100,50,33 + " except ZeroDivisionError:\n" + + " total = total + 1\n" + // i=0 -> +1 + " print(total)\n"; + RunSeed(body, 4, 1).Should().Equal(184); // 1 + 100 + 50 + 33 + } + + [Test] + public void SequentialTryBlocks() + { + // Two sequential try blocks: T must be reset after the first so the second works. + const string body = + "def run(s: uint8):\n" + + " try:\n" + + " print(100 // s)\n" + + " except ZeroDivisionError:\n" + + " print(1)\n" + + " try:\n" + + " print(50 // s)\n" + + " except ZeroDivisionError:\n" + + " print(2)\n"; + RunSeed(body, 0, 2).Should().Equal(1, 2); + } + + [Test] + public void ReturnInExceptHandlerRunsFinally() + { + // return inside an except handler must still run the finally (the flagged limitation). + const string body = + "def f(s: uint8) -> uint8:\n" + + " try:\n" + + " x: uint8 = 100 // s\n" + // s=0 -> ZeroDivisionError + " return x\n" + + " except ZeroDivisionError:\n" + + " return 5\n" + // return in handler -> finally must run first + " finally:\n" + + " print(8)\n" + + "def run(s: uint8):\n" + + " print(f(s))\n"; + RunSeed(body, 0, 2).Should().Equal(8, 5); + } + + [Test] + public void RaiseFromMethodCaught() + { + const string body = + "class Sensor:\n" + + " def __init__(self):\n self._x = 0\n" + + " def read(self, s: uint8) -> uint8:\n return 100 // s\n" + // s=0 raises + "def run(s: uint8):\n" + + " sensor = Sensor()\n" + + " try:\n" + + " print(sensor.read(s))\n" + + " except ZeroDivisionError:\n" + + " print(4)\n"; + RunSeed(body, 0, 1).Should().Equal(4); + } + + [Test] + public void NestedFinallyOnReturn() + { + // return through two nested try-with-finally levels runs both, innermost first. + const string body = + "def f(s: uint8) -> uint8:\n" + + " try:\n" + + " try:\n" + + " return s\n" + + " finally:\n" + + " print(1)\n" + // inner finally first + " finally:\n" + + " print(2)\n" + // then outer + "def run(s: uint8):\n" + + " print(f(s))\n"; + RunSeed(body, 5, 3).Should().Equal(1, 2, 5); + } + + [Test] + public void NestedFinallyOnBreak() + { + // break through two nested finally levels runs both. + const string body = + "def run(s: uint8):\n" + + " t: uint8 = 0\n" + + " for i in range(s):\n" + + " try:\n" + + " try:\n" + + " if i == 1:\n break\n" + + " t = t + i\n" + + " finally:\n" + + " t = t + 10\n" + // inner + " finally:\n" + + " t = t + 100\n" + // outer + " print(t)\n"; + // i=0: +0,+10,+100=110 ; i=1: break -> +10,+100 -> 220 + RunSeed(body, 5, 1).Should().Equal(220); + } + + [Test] + public void RaiseInConstructorCaught() + { + const string body = + "class Thing:\n" + + " def __init__(self, s: uint8):\n self._v = 100 // s\n" + // s=0 raises + "def run(s: uint8):\n" + + " try:\n" + + " t = Thing(s)\n" + + " print(t._v)\n" + + " except ZeroDivisionError:\n" + + " print(6)\n"; + RunSeed(body, 0, 1).Should().Equal(6); + } + + [Test] + public void BareReraisePreservesCodeAfterClobber() + { + // The handler runs a 2-arg call (its 2nd arg lands in R22, the error-code register) before + // a bare raise. The re-raised exception must still be ZeroDivisionError, not garbage. + const string body = + "def add2(a: uint8, b: uint8) -> uint8:\n return a + b\n" + + "def inner(s: uint8) -> uint8:\n" + + " try:\n" + + " return 100 // s\n" + // ZeroDivisionError (code 6) + " except ZeroDivisionError:\n" + + " print(add2(1, 2))\n" + // clobbers R22 with arg b + " raise\n" + // bare re-raise -> must still be ZeroDivisionError + "def run(s: uint8):\n" + + " try:\n" + + " print(inner(s))\n" + + " except ValueError:\n" + + " print(1)\n" + // wrong (if R22 clobbered to some other code) + " except ZeroDivisionError:\n" + + " print(7)\n"; // correct + RunSeed(body, 0, 2).Should().Equal(3, 7); + } + + [Test] + public void BareReraise() + { + // `raise` with no argument re-raises the current exception (Python). + const string body = + "def inner(s: uint8) -> uint8:\n" + + " try:\n" + + " return 100 // s\n" + + " except ZeroDivisionError:\n" + + " raise\n" + // bare re-raise + "def run(s: uint8):\n" + + " try:\n" + + " print(inner(s))\n" + + " except ZeroDivisionError:\n" + + " print(7)\n"; + RunSeed(body, 0, 1).Should().Equal(7); + } + + [Test] + public void FinallyExceptionOverridesPending() + { + const string body = + "def f(s: uint8):\n" + + " try:\n" + + " raise ValueError\n" + // body raises ValueError + " finally:\n" + + " x: uint8 = 100 // s\n" + // s=0 -> finally raises ZeroDivisionError (overrides) + "def run(s: uint8):\n" + + " try:\n" + + " f(s)\n" + + " except ValueError:\n" + + " print(1)\n" + // must NOT catch + " except ZeroDivisionError:\n" + + " print(2)\n"; // overriding exception + RunSeed(body, 0, 1).Should().Equal(2); + } + + [Test] + public void ModuloByZeroRaises() + { + const string body = + "def run(s: uint8):\n" + + " try:\n" + + " print(100 % s)\n" + // s=0 -> ZeroDivisionError from modulo + " except ZeroDivisionError:\n" + + " print(5)\n"; + RunSeed(body, 0, 1).Should().Equal(5); + } + + [Test] + public void NestedBareReraise() + { + const string body = + "def level2(s: uint8) -> uint8:\n" + + " try:\n return 100 // s\n" + + " except ZeroDivisionError:\n raise\n" + // re-raise + "def level1(s: uint8) -> uint8:\n" + + " try:\n return level2(s)\n" + + " except ZeroDivisionError:\n raise\n" + // re-raise again + "def run(s: uint8):\n" + + " try:\n print(level1(s))\n" + + " except ZeroDivisionError:\n print(8)\n"; + RunSeed(body, 0, 1).Should().Equal(8); + } + + [Test] + public void BreakInFinallySwallowsException() + { + // Python: a break in a finally discards the in-flight exception and exits the loop. + const string body = + "def run(s: uint8):\n" + + " for i in range(s):\n" + // s=3 + " try:\n" + + " raise ValueError\n" + + " finally:\n" + + " break\n" + // swallows the ValueError, exits loop + " print(9)\n"; + RunSeed(body, 3, 1).Should().Equal(9); + } + + [Test] + public void ReturnInFinallySwallowsException() + { + // Python: a return in a finally discards the in-flight exception and returns normally. + const string body = + "def f(s: uint8) -> uint8:\n" + + " try:\n" + + " raise ValueError\n" + + " finally:\n" + + " return 5\n" + // swallows the ValueError, returns 5 + "def run(s: uint8):\n" + + " print(f(s))\n"; + RunSeed(body, 0, 1).Should().Equal(5); + } + + [Test] + public void HandlerRaisesNewExceptionCaughtByOuter() + { + const string body = + "def run(s: uint8):\n" + + " try:\n" + + " try:\n" + + " raise ValueError\n" + + " except ValueError:\n" + + " raise IndexError\n" + // handler raises a different exception + " except IndexError:\n" + + " print(3)\n"; + RunSeed(body, 0, 1).Should().Equal(3); + } + + [Test] + public void FinallyRunsBeforePropagation() + { + const string body = + "def f(s: uint8) -> uint8:\n" + + " try:\n" + + " return 100 // s\n" + // s=0 -> exception + " finally:\n" + + " print(8)\n" + // must run before the error propagates + "def run(s: uint8):\n" + + " try:\n" + + " print(f(s))\n" + + " except ZeroDivisionError:\n" + + " print(3)\n"; + RunSeed(body, 0, 2).Should().Equal(8, 3); + } + + [Test] + public void FinallyRunsBeforeReturn() + { + const string body = + "def f(s: uint8) -> uint8:\n" + + " try:\n" + + " return s * 2\n" + // returns, but finally runs first + " finally:\n" + + " print(7)\n" + + "def run(s: uint8):\n" + + " r: uint8 = f(s)\n" + + " print(r)\n"; + RunSeed(body, 5, 2).Should().Equal(7, 10); + } + + [Test] + public void InlineRaisePropagatesToCaller() + { + const string body = + "from pymcu.types import inline\n\n" + + "@inline\n" + + "def checked(s: uint8) -> uint8:\n" + + " if s == 0:\n raise ValueError\n" + + " return 100 // s\n" + + "def run(s: uint8):\n" + + " try:\n" + + " print(checked(s))\n" + + " except ValueError:\n" + + " print(4)\n"; + RunSeed(body, 0, 1).Should().Equal(4); + } + + [Test] + public void UncaughtExceptionHalts() + { + // An UNCAUGHT runtime divide-by-zero must now halt (loud failure, Python-like) rather than + // silently continue with garbage: the code after the faulting division must not run. + const string src = + "from pymcu.types import uint8\n" + + "from pymcu.hal.uart import UART\n\n\n" + + "def divide(a: uint8, b: uint8) -> uint8:\n" + + " return a // b\n" + + "def main():\n" + + " uart = UART(9600)\n" + + " uart.println(\"GO\")\n" + + " s: uint8 = uart.read_blocking()\n" + + " r: uint8 = divide(100, s)\n" + // s=0 -> uncaught ZeroDivisionError -> halt + " uart.println(\"AFTER\")\n" + // must NOT print (device halted) + " while True:\n pass\n"; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(0); + // The device halts at the unhandled-exception handler, so "AFTER" never arrives — the + // wait is expected to time out. That timeout IS the pass condition (no silent continue). + try { uno.RunUntilSerial(uno.Serial, t => t.Contains("AFTER"), maxMs: 300); } + catch (TimeoutException) { } + uno.Serial.Text.Should().NotContain("AFTER"); + } + + [Test] + public void RuntimeDivByZeroRaises() + { + // Runtime divide-by-zero now raises ZeroDivisionError (Python fidelity), catchable here. + const string body = + "def run(s: uint8):\n" + + " a: uint8 = 100\n" + + " try:\n" + + " print(a // s)\n" + // s=0 -> ZeroDivisionError + " except ZeroDivisionError:\n" + + " print(77)\n"; + RunSeed(body, 0, 1).Should().Equal(77); + } + + [Test] + public void RuntimeDivByNonZeroWorks() + { + const string body = + "def run(s: uint8):\n" + + " a: uint8 = 100\n" + + " try:\n" + + " print(a // s)\n" + // s=5 -> 20, no raise + " except ZeroDivisionError:\n" + + " print(77)\n"; + RunSeed(body, 5, 1).Should().Equal(20); + } + + [Test] + public void AugAssignInstanceField() + { + const string body = + "class Counter:\n" + + " def __init__(self):\n self._n = 0\n" + + " def add(self, v: uint8):\n self._n += v\n" + + " def get(self) -> uint8:\n return self._n\n" + + "def run(s: uint8):\n" + + " c = Counter()\n" + + " c.add(s)\n" + + " c.add(10)\n" + + " print(c.get())\n"; // s=5: 5+10 = 15 + RunSeed(body, 5, 1).Should().Equal(15); + } + + [Test] + public void SliceWithStep() + { + const string body = + "def run(s: uint8):\n" + + " a: uint8[6] = [10, 20, 30, 40, 50, 60]\n" + + " a[0] = s\n" + + " b = a[0:6:2]\n" + // indices 0,2,4 -> [s,30,50] + " print(b[0])\n" + // s=5 + " print(b[1])\n" + // 30 + " print(b[2])\n"; // 50 + RunSeed(body, 5, 3).Should().Equal(5, 30, 50); + } + + [Test] + public void SixArgsViaSpill() + { + // Six uint8 args: the first five use R24,R22,R20,R18,R16; the sixth overflows to the SRAM + // spill region. Each must arrive intact. Position-encode so a dropped/corrupted arg shows. + const string body = + "def f6(a: uint8, b: uint8, c: uint8, d: uint8, e: uint8, g: uint8) -> uint8:\n" + + " return a + b * 2 + c * 4 + d * 8 + e * 16 + g * 32\n" + + "def run(s: uint8):\n" + + " print(f6(1, 1, 1, 1, 1, 1))\n" + // 1+2+4+8+16+32 = 63 + " print(f6(s, 0, 0, 0, 0, 1))\n"; // s + 32 ; s=5 -> 37 + RunSeed(body, 5, 2).Should().Equal(63, 37); + } + + [Test] + public void EightArgsViaSpill() + { + // Eight uint8 args: three overflow to SRAM (16-bit spill offsets exercised too). + const string body = + "def f8(a: uint8, b: uint8, c: uint8, d: uint8, e: uint8, f: uint8, g: uint8, h: uint8) -> uint16:\n" + + " return uint16(a) + b * 2 + c * 4 + d * 8 + e * 16 + f * 32 + g * 64 + h * 128\n" + + "def run(s: uint8):\n" + + " print(f8(1, 1, 1, 1, 1, 1, 1, 1))\n"; // sum of powers 1..128 = 255 + RunSeed(body, 5, 1).Should().Equal(255); + } + + [Test] + public void SpilledArgsWith16BitMix() + { + // A uint16 arg in the spill region (2-byte spill offset) must round-trip. + const string body = + "from pymcu.types import uint16\n\n" + + "def f(a: uint8, b: uint8, c: uint8, d: uint8, e: uint8, w: uint16) -> uint16:\n" + + " return uint16(a + b + c + d + e) + w\n" + + "def run(s: uint8):\n" + + " print(f(1, 2, 3, 4, 5, 1000))\n"; // 15 + 1000 = 1015 + RunSeed(body, 5, 1).Should().Equal(1015); + } + + [Test] + public void InlineRuntimeIndexedLocalArray() + { + // A runtime-indexed local array inside an @inline function must be allocated as SRAM when + // the function is expanded (regression: it hit "subscript must be a compile-time constant" + // because the per-function prescan never saw the inlined callee's locals). + const string body = + "from pymcu.types import inline\n\n" + + "@inline\n" + + "def rev_sum(n: uint8) -> uint8:\n" + + " buf: uint8[8] = [0, 0, 0, 0, 0, 0, 0, 0]\n" + + " i: uint8 = 0\n" + + " while i < n:\n" + + " buf[i] = i * 2\n" + // runtime index store + " i = i + 1\n" + + " acc: uint8 = 0\n" + + " j: uint8 = 0\n" + + " while j < n:\n" + + " acc = acc + buf[j]\n" + // runtime index load + " j = j + 1\n" + + " return acc\n" + + "def run(s: uint8):\n" + + " print(rev_sum(s))\n"; // s=5: 0+2+4+6+8 = 20 + RunSeed(body, 5, 1).Should().Equal(20); + } + + [Test] + public void LcdPrintStrFStringCompilesAndRuns() + { + // lcd.print_str(f"...") lowers to print_str("literal") + print_fmt(value,...) on the same + // LCD instance. Verify it builds and the program reaches its UART banner (the LCD format + // code is valid and executes), consistent with the existing LCD test rigor. + const string src = + "from pymcu.types import uint8, uint16\n" + + "from pymcu.hal.uart import UART\n" + + "from pymcu.drivers.lcd import LCD\n\n\n" + + "def main():\n" + + " uart = UART(9600)\n" + + " lcd = LCD(rs=\"PD4\", en=\"PD5\", d4=\"PD6\", d5=\"PD7\", d6=\"PB0\", d7=\"PB1\")\n" + + " lcd.init()\n" + + " uart.println(\"GO\")\n" + + " s: uint8 = uart.read_blocking()\n" + + " n: uint16 = uint16(s) * 100\n" + + " lcd.print_str(f\"T={s} 0x{n:04x}\")\n" + + " uart.println(\"DONE\")\n" + + " while True:\n pass\n"; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 1000); + uno.Serial.InjectByte(7); + uno.RunUntilSerial(uno.Serial, t => t.Contains("DONE"), maxMs: 1000); + uno.Serial.Text.Should().Contain("DONE"); + } + + [Test] + public void FiveArgsAllArrive() + { + // Five uint8 args fit R24,R22,R20,R18,R16 (all >= R16). Each must arrive — the call/callee + // loops used to cap at 4 and silently dropped args 5+. Position-encode to expose a drop. + const string body = + "def f5(a: uint8, b: uint8, c: uint8, d: uint8, e: uint8) -> uint8:\n" + + " return a + b * 2 + c * 4 + d * 8 + e * 16\n" + + "def run(s: uint8):\n" + + " print(f5(1, 1, 1, 1, 1))\n" + // 1+2+4+8+16 = 31 + " print(f5(s, 0, 0, 0, 1))\n"; // s + 16 ; s=5 -> 21 + RunSeed(body, 5, 2).Should().Equal(31, 21); + } + + [Test] + public void FStringFormatSpecs() + { + const string src = + "from pymcu.types import uint8, uint16\n" + + "from pymcu.hal.uart import UART\n\n\n" + + "def main():\n" + + " uart = UART(9600)\n" + + " uart.println(\"GO\")\n" + + " s: uint8 = uart.read_blocking()\n" + + " n: uint16 = uint16(s) * 50\n" + // 250 * 50 = ... s=10 -> 500 + " print(f\"hex={s:02x} HEX={n:04X} bin={s:08b} pad={s:4d}!\")\n" + + " while True:\n pass\n"; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(10); + uno.RunUntilSerial(uno.Serial, t => t.Contains("!"), maxMs: 6000); + // s=10: hex=0a, n=500=0x1F4 -> 01F4, bin(10)=00001010, pad " 10" + uno.Serial.Text.Should().Contain("hex=0a HEX=01F4 bin=00001010 pad= 10!"); + } + + [Test] + public void SignedWidenToInt32() + { + const string body = + "from pymcu.types import int16, int32\n\n" + + "def run(s: uint8):\n" + + " neg: int16 = 0 - int16(s)\n" + // -8 + " w: int32 = int32(neg)\n" + + " print(neg)\n" + // -8 + " print(w)\n"; // -8 + RunSeed(body, 8, 2).Should().Equal(-8, -8); + } + + [Test] + public void FStringFormatSignedAndOctal() + { + const string src = + "from pymcu.types import uint8, int16\n" + + "from pymcu.hal.uart import UART\n\n\n" + + "def main():\n" + + " uart = UART(9600)\n" + + " uart.println(\"GO\")\n" + + " s: uint8 = uart.read_blocking()\n" + + " neg: int16 = 0 - int16(s)\n" + // -5 + " print(f\"[{neg:d}][{neg:5d}][{s:o}]!\")\n" + + " while True:\n pass\n"; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(8); + uno.RunUntilSerial(uno.Serial, t => t.Contains("!"), maxMs: 6000); + // neg=-8: "-8"; ":5d" -> " -8" (width 5); s=8 octal -> "10" + uno.Serial.Text.Should().Contain("[-8][ -8][10]!"); + } + + [Test] + public void MixedSignedUnsignedComparison() + { + // The real C gotcha: comparing a signed and an unsigned value. Python compares by value + // (-5 < 200 is True); a C-style promotion to unsigned would give 65531 < 200 = False. + const string body = + "from pymcu.types import int16, uint16\n\n" + + "def run(s: uint8):\n" + + " neg: int16 = 0 - int16(s)\n" + // -5 + " pos: uint16 = 200\n" + + " print(1 if neg < pos else 0)\n" + // -5 < 200 -> 1 + " print(1 if pos > neg else 0)\n"; // 200 > -5 -> 1 + RunSeed(body, 5, 2).Should().Equal(1, 1); + } + + [Test] + public void UartWriteStrAndPrintlnFString() + { + const string src = + "from pymcu.types import uint8, uint16\n" + + "from pymcu.hal.uart import UART\n\n\n" + + "def main():\n" + + " uart = UART(9600)\n" + + " uart.println(\"GO\")\n" + + " s: uint8 = uart.read_blocking()\n" + + " n: uint16 = uint16(s) * 100\n" + + " uart.write_str(f\"a={s} b={n};\")\n" + + " uart.println(f\"line={s}\")\n" + + " while True:\n pass\n"; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(5); + uno.RunUntilSerial(uno.Serial, t => NL(t) >= 2, maxMs: 6000); + uno.Serial.Text.Should().Contain("a=5 b=500;"); + uno.Serial.Text.Should().Contain("line=5\n"); + } + + [Test] + public void FloorDivMod_NegativeDividend() + { + // Python `//` floors toward -inf and `%` follows the divisor's sign (NOT C truncation). + const string body = + "from pymcu.types import int16\n\n" + + "def run(s: uint8):\n" + + " n: int16 = 0 - s\n" + // -7 + " print(n // 2)\n" + // Python floors: -4 (C trunc: -3) + " print(n % 2)\n"; // Python: 1 (C: -1) + RunSeed(body, 7, 2).Should().Equal(-4, 1); + } + + [Test] + public void FloorDivMod_NegativeDivisor() + { + const string body = + "from pymcu.types import int16\n\n" + + "def run(s: uint8):\n" + + " n: int16 = s\n" + // 7 + " print(n // -2)\n" + // Python: -4 (floor of -3.5) + " print(n % -2)\n"; // Python: -1 (sign follows divisor) + RunSeed(body, 7, 2).Should().Equal(-4, -1); + } + + [Test] + public void Power_RuntimeBaseConstantExponent() + { + // s ** 2 lowers to repeated multiplication with promotion: 5*5 = 25, and a base that + // overflows its width keeps the wide value (10 ** 3 = 1000, promoted past uint8). + const string body = + "def run(s: uint8):\n" + + " print(s ** 2)\n" + // 5 -> 25 + " print(s ** 3)\n" + // 5 -> 125 + " print(s ** 0)\n" + // 1 + " print(s ** 1)\n"; // 5 + RunSeed(body, 5, 4).Should().Equal(25, 125, 1, 5); + } + + [Test] + public void Power_WideResult() + { + // 10 ** 3 = 1000 must not truncate to uint8 (the promotion chain widens to uint16+). + const string body = + "def run(s: uint8):\n" + + " print(s ** 3)\n"; // 10 -> 1000 + RunSeed(body, 10, 1).Should().Equal(1000); + } + + [Test] + public void SwapUnpack() + { + // Tuple swap `a, b = b, a` evaluates the RHS tuple before binding (no clobber). + const string body = + "def run(s: uint8):\n" + + " a: uint8 = s\n" + + " b: uint8 = 100\n" + + " a, b = b, a\n" + + " print(a)\n" + // 100 + " print(b)\n"; // 5 + RunSeed(body, 5, 2).Should().Equal(100, 5); + } + + [Test] + public void ChainAssign() + { + // Chained assignment `a = b = s` binds both names to the same value. + const string body = + "def run(s: uint8):\n" + + " a: uint8 = 0\n" + + " b: uint8 = 0\n" + + " a = b = s\n" + + " print(a)\n" + // 5 + " print(b)\n"; // 5 + RunSeed(body, 5, 2).Should().Equal(5, 5); + } + + [Test] + public void Promote_Uint8AddWidensToUint16() + { + // Python fidelity: uint8 + uint8 promotes to uint16, so 255 + 45 = 300 (NOT 44). + // s comes over UART so the add is a real runtime op, not constant-folded. + const string body = + "def run(s: uint8):\n" + + " r = s + 45\n" + // 255 + 45 = 300 (promoted, no wrap) + " print(r)\n"; + RunSeed(body, 255, 1).Should().Equal(300); + } + + [Test] + public void Promote_ExplicitCastOptsOutToFixedWidth() + { + // The uint8(...) escape hatch forces a fixed-width 8-bit op: 255 + 45 = 300 wraps to 44. + const string body = + "def run(s: uint8):\n" + + " r: uint8 = uint8(s + 45)\n" + // computed at 8-bit -> 0x12C & 0xFF = 44 + " print(r)\n"; + RunSeed(body, 255, 1).Should().Equal(44); + } + + [Test] + public void Promote_Uint16WrapsOnExplicitStore() + { + // Promotion widens the temp, but the declared type is the STORAGE width: assigning the + // promoted result back into a uint16 truncates. 65535 + 1 = 65536 -> stored uint16 = 0, + // and the optimizer must mask its tracked constant so `e == 0` is true at runtime. + const string body = + "from pymcu.types import uint16\n\n" + + "def run(s: uint8):\n" + + " e: uint16 = 65535\n" + + " e = e + s\n" + // s=1 -> 65536 wraps to 0 on store + " print(e)\n" + // 0 + " print(1 if e == 0 else 9)\n"; // 1 (not folded to 9) + RunSeed(body, 1, 2).Should().Equal(0, 1); + } + + [Test] + public void Promote_Uint16MulWidensToUint32() + { + // uint16 * int promotes to uint32, so a result that overflows 16 bits is kept intact. + // This is the LoadIntoReg widening path: a uint16 source must zero-extend its real high + // byte into the uint32 destination (the bug gave 88 instead of the wide value). + const string body = + "from pymcu.types import uint16, uint32\n\n" + + "def run(s: uint8):\n" + + " a: uint16 = 60000\n" + + " a = a + s\n" + // 60005 (fits uint16) + " big: uint32 = a * 2\n" + // 120010 -> needs uint32 + " print(big)\n"; + RunSeed(body, 5, 1).Should().Equal(120010); + } +} diff --git a/tests/integration/Tests/AVR/FloatCastTests.cs b/tests/integration/Tests/AVR/FloatCastTests.cs new file mode 100644 index 00000000..12752e6a --- /dev/null +++ b/tests/integration/Tests/AVR/FloatCastTests.cs @@ -0,0 +1,43 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// float(x) builtin: converts an integer (or is the identity on a float) to floating point, +/// matching Python. Previously undefined ("call to undefined function 'float'"). The int->float +/// conversion is the same the implicit mixed-arithmetic path uses. Seed-derived so nothing folds. +/// +[TestFixture] +public class FloatCastTests +{ + [Test] + public void FloatOfInt_ConvertsAndComputes() + { + const string src = """ +from pymcu.types import uint8 +from pymcu.hal.uart import UART + + +def main(): + uart = UART(9600) + uart.println("GO") + s: uint8 = uart.read_blocking() + print(float(s)) + print(float(s) / 2.0) + while True: + pass +"""; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(5); + uno.RunUntilSerial(uno.Serial, t => t.Replace("\r", "").Split('\n').Length >= 4, maxMs: 3000); + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + lines[start + 1].Trim().Should().Be("5.0"); + lines[start + 2].Trim().Should().Be("2.5"); + } +} diff --git a/tests/integration/Tests/AVR/GlobalInitTests.cs b/tests/integration/Tests/AVR/GlobalInitTests.cs new file mode 100644 index 00000000..766510d2 --- /dev/null +++ b/tests/integration/Tests/AVR/GlobalInitTests.cs @@ -0,0 +1,37 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration tests for fixtures/global-init — a module-level mutable global with +/// a non-zero initializer (`_seed: uint16 = 3007`). It previously landed in BSS with +/// the initializer dropped and read 0; the fix injects the init into main(). The +/// fixture prints _seed (expects 3007) then increments it at runtime and prints +/// again (3008), proving it is a real RAM cell seeded to the literal value. +/// +[TestFixture] +public class GlobalInitTests +{ + private static SimSession _session = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _session = new SimSession(PymcuCompiler.BuildFixture("global-init")); + + [Test] + public void NonZeroInitializer_IsWrittenAtStartup() + { + var uno = _session.Reset(); + uno.RunUntilSerial(uno.Serial, s => s.Contains("3007\n"), maxMs: 400); + uno.Serial.Text.Should().Contain("3007", "the global is seeded to its literal, not 0"); + } + + [Test] + public void Global_IsAMutableRamCell() + { + var uno = _session.Reset(); + uno.RunUntilSerial(uno.Serial, s => s.Contains("3007\n3008\n"), maxMs: 400); + uno.Serial.Text.Should().Contain("3007\n3008", "the runtime increment proves it is RAM, not a constant"); + } +} diff --git a/tests/integration/Tests/AVR/LoopSemanticsTests.cs b/tests/integration/Tests/AVR/LoopSemanticsTests.cs new file mode 100644 index 00000000..9385a7ec --- /dev/null +++ b/tests/integration/Tests/AVR/LoopSemanticsTests.cs @@ -0,0 +1,101 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Python fidelity for `for ... in range(...)` loops: +/// * the loop variable read inside the body must hold the iteration value (it was bound to a +/// differently-qualified name than the body resolved, so `for i in range(n): acc += i` +/// summed zeros), and +/// * a negative step must count down (the runtime loop used an ascending-only exit test, so +/// `range(hi, lo, -1)` exited immediately). +/// Covered in main and inside a def (the function-qualified name path), with constant and +/// runtime bounds. Values derive from a runtime seed so nothing constant-folds. +/// +[TestFixture] +public class LoopSemanticsTests +{ + private static List Run(string body, byte seed, int wantLines) + { + string src = + "from pymcu.types import uint8\n" + + "from pymcu.hal.uart import UART\n\n\n" + + body + + "\ndef main():\n" + + " uart = UART(9600)\n" + + " uart.println(\"GO\")\n" + + " s: uint8 = uart.read_blocking()\n" + + " run(s)\n" + + " while True:\n pass\n"; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(seed); + uno.RunUntilSerial(uno.Serial, t => t.Replace("\r", "").Split('\n').Length >= wantLines + 2, maxMs: 4000); + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var outp = new List(); + for (int i = start + 1; i < lines.Length && outp.Count < wantLines; i++) + { + var t = lines[i].Trim(); + if (t.Length > 0 && int.TryParse(t, out int v)) outp.Add(v); + } + return outp; + } + + // run(s) lives in a def, so the loop variable resolves through the function-qualified + // ("run.i") name — the exact path that was mismatched. + [Test] + public void LoopVariable_And_NegativeStep_InFunction() + { + const string body = """ +def run(s: uint8): + uart = UART(9600) + acc: uint8 = 0 + for i in range(0, 5): + acc = acc + i + print(acc) # 0+1+2+3+4 = 10 + n: uint8 = s + acc2: uint8 = 0 + for i in range(0, n): + acc2 = acc2 + i + print(acc2) # s=5 -> 0+1+2+3+4 = 10 + step2: uint8 = 0 + for i in range(0, 10, 2): + step2 = step2 + i + print(step2) # 0+2+4+6+8 = 20 + dn: uint8 = 0 + for j in range(5, 0, -1): + dn = dn + j + print(dn) # 5+4+3+2+1 = 15 +"""; + Run(body, 5, 4).Should().Equal(new List { 10, 10, 20, 15 }); + } + + // `continue` in a for-range must advance the loop variable (it jumped to the condition + // before the step, so a taken continue spun forever); `break` exits. + [Test] + public void ContinueAndBreak_InRange() + { + const string body = """ +def run(s: uint8): + uart = UART(9600) + c1: uint8 = 0 + for i in range(0, 10): + if (i & 1) == 1: + continue + c1 = c1 + i + print(c1) # 0+2+4+6+8 = 20 + b1: uint8 = 0 + for i in range(0, 100): + if i == 5: + break + b1 = b1 + 1 + print(b1) # 5 +"""; + Run(body, 5, 2).Should().Equal(new List { 20, 5 }); + } +} diff --git a/tests/integration/Tests/AVR/ModuleAliasTests.cs b/tests/integration/Tests/AVR/ModuleAliasTests.cs new file mode 100644 index 00000000..dc97a3da --- /dev/null +++ b/tests/integration/Tests/AVR/ModuleAliasTests.cs @@ -0,0 +1,29 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration tests for fixtures/module-alias — aliased and comma-separated +/// module imports on the MicroPython layer (import machine as m, time as t). +/// m.UART / m.Pin / t.sleep_ms used to mangle to undefined symbols; reaching the +/// "MA" banner over UART proves the alias resolves to the real module and the +/// constructor, member and method calls all compile and run. +/// +[TestFixture] +public class ModuleAliasTests +{ + private static SimSession _session = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _session = new SimSession(PymcuCompiler.BuildFixture("module-alias")); + + [Test] + public void AliasedModules_ResolveAndRun() + { + var uno = _session.Reset(); + uno.RunUntilSerial(uno.Serial, s => s.Contains("MA"), maxMs: 300); + uno.Serial.Text.Should().Contain("MA", "m.UART resolves to machine's UART and writes the banner"); + } +} diff --git a/tests/integration/Tests/AVR/Mul32Tests.cs b/tests/integration/Tests/AVR/Mul32Tests.cs new file mode 100644 index 00000000..231c14d2 --- /dev/null +++ b/tests/integration/Tests/AVR/Mul32Tests.cs @@ -0,0 +1,65 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Unsigned 32-bit multiplication (low 32 bits) via __mul32. The is32 Mul case previously fell +/// through to an 8-bit MUL, silently truncating (5000000 -> 64). Operands are derived from a +/// runtime seed so nothing constant-folds; results are compared against a C# oracle masked to +/// 32 bits, including a product that overflows 2^32. +/// +[TestFixture] +public class Mul32Tests +{ + private static int NL(string s) { int n = 0; foreach (var c in s) if (c == '\n') n++; return n; } + + [Test] + public void Mul32_LowWord_MatchesOracle() + { + // base = s = 5. Each operand = base + (target - 5), i.e. the target at runtime. + (long a, long b)[] pairs = + { + (5, 1000000), // 5000000 + (70000, 70000), // 4_900_000_000 -> wraps mod 2^32 + (65536, 65536), // 2^32 -> wraps to 0 + (16777216, 200), // 2^24 * 200 -> wraps + (123456, 9999), // 1_234_437_744 + }; + var body = new System.Text.StringBuilder(); + var expected = new List(); + int n = 0; + foreach (var (a, b) in pairs) + { + body.Append($" a{n}: uint32 = uint32(s) + {a - 5}\n"); + body.Append($" b{n}: uint32 = uint32(s) + {b - 5}\n"); + body.Append($" p{n}: uint32 = a{n} * b{n}\n"); + body.Append($" print(p{n})\n"); + expected.Add(unchecked((uint)(a * b))); + n++; + } + string src = + "from pymcu.types import uint8, uint32\n" + + "from pymcu.hal.uart import UART\n\n\n" + + "def main():\n" + + " uart = UART(9600)\n" + + " uart.println(\"GO\")\n" + + " s: uint8 = uart.read_blocking()\n" + + body + + " while True:\n pass\n"; + + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(5); + uno.RunUntilSerial(uno.Serial, t => NL(t) >= pairs.Length + 1, maxMs: 6000); + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var got = new List(); + for (int i = start + 1; i < lines.Length && got.Count < pairs.Length; i++) + if (long.TryParse(lines[i].Trim(), out long v)) got.Add(v); + got.Should().Equal(expected); + } +} diff --git a/tests/integration/Tests/AVR/PrintInt32Tests.cs b/tests/integration/Tests/AVR/PrintInt32Tests.cs new file mode 100644 index 00000000..ed1adc91 --- /dev/null +++ b/tests/integration/Tests/AVR/PrintInt32Tests.cs @@ -0,0 +1,62 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// print(int32) must format with its sign over the full 32-bit range. Previously INT32 fell +/// through to uart_write_decimal_i16, truncating to 16 bits; it now routes to the new +/// uart_write_decimal_i32 (sign + uart_write_decimal_u32 magnitude). Values are derived from a +/// runtime seed so nothing constant-folds. +/// +[TestFixture] +public class PrintInt32Tests +{ + private static List Run(string body, int wantLines) + { + string src = + "from pymcu.types import int32, uint8\n" + + "from pymcu.hal.uart import UART\n\n\n" + + "def main():\n" + + " uart = UART(9600)\n" + + " uart.println(\"GO\")\n" + + " s: uint8 = uart.read_blocking()\n" + + " base: int32 = int32(s)\n" + // = 0 at runtime, compiler-opaque + body + + " while True:\n pass\n"; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(0); + uno.RunUntilSerial(uno.Serial, s => s.Replace("\r", "").Split('\n').Length >= wantLines + 2, maxMs: 4000); + + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var outp = new List(); + for (int i = start + 1; i < lines.Length && outp.Count < wantLines; i++) + { + var t = lines[i].Trim(); + if (t.Length > 0) outp.Add(t); + } + return outp; + } + + [Test] + public void FullRange_PrintsSignedDecimal() + { + // base == 0 at runtime, so each prints its literal — but not constant-folded. + var got = Run( + " a: int32 = base + 2147483647\n print(a)\n" + // INT32_MAX + " b: int32 = base + (-2147483647 - 1)\n print(b)\n" + // INT32_MIN + " c: int32 = base + -1\n print(c)\n" + + " d: int32 = base + -1000000\n print(d)\n" + + " e: int32 = base + 1000000\n print(e)\n" + + " f: int32 = base + -42\n print(f)\n", 6); + got.Should().Equal(new List + { + "2147483647", "-2147483648", "-1", "-1000000", "1000000", "-42", + }); + } +} diff --git a/tests/integration/Tests/AVR/PtrOffsetTests.cs b/tests/integration/Tests/AVR/PtrOffsetTests.cs new file mode 100644 index 00000000..9d5b9293 --- /dev/null +++ b/tests/integration/Tests/AVR/PtrOffsetTests.cs @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; +using Avr8Sharp.TestKit; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration test for ptr(BASE + offset) compile-time address arithmetic. +/// The fixture writes PORTB through ptr(PINB + 2): PINB=0x23, +2 = PORTB=0x25. +/// If the offset is applied to the register's ADDRESS (correct) the pins go high; +/// dereferencing PINB instead would compute a garbage address and miss PORTB. +/// +[TestFixture] +public class PtrOffsetTests +{ + private SimSession _session = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _session = new SimSession(PymcuCompiler.BuildFixture("ptr-offset")); + + [Test] + public void PtrPlusOffset_LandsOnPortB_DrivesPinsHigh() + { + var uno = _session.Reset(); + uno.RunMilliseconds(5); + uno.PortB.Should().HavePinHigh(5); + uno.PortB.Should().HavePinHigh(0); + } +} diff --git a/tests/integration/Tests/AVR/PtrRuntimeTests.cs b/tests/integration/Tests/AVR/PtrRuntimeTests.cs new file mode 100644 index 00000000..c71f9175 --- /dev/null +++ b/tests/integration/Tests/AVR/PtrRuntimeTests.cs @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; +using Avr8Sharp.TestKit; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration test for runtime-offset pointers: ptr(BASE + offset).value through +/// Store/Load indirect. Writes 40 to a free SRAM slot via the pointer, augments it +/// by 2 (read-modify-write), reads it back through a freshly computed pointer to the +/// same address, and emits the result over UART. A correct round-trip yields 42. +/// +[TestFixture] +public class PtrRuntimeTests +{ + private SimSession _session = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _session = new SimSession(PymcuCompiler.BuildFixture("ptr-runtime")); + + [Test] + public void RuntimePointer_WriteAugAssignRead_RoundTrips() + { + var uno = _session.Reset(); + uno.RunUntilSerialBytes(uno.Serial, 4, maxMs: 500); // "PR\n" (3) + result byte + var bytes = uno.Serial.Bytes; + bytes[^1].Should().Be(42, "40 written, += 2 via indirect RMW, read back through ptr(BASE+off)"); + } +} diff --git a/tests/integration/Tests/AVR/RaiseInTryTests.cs b/tests/integration/Tests/AVR/RaiseInTryTests.cs new file mode 100644 index 00000000..b99e8108 --- /dev/null +++ b/tests/integration/Tests/AVR/RaiseInTryTests.cs @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; +using Avr8Sharp.TestKit; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration tests for the raise-in-try fixture. +/// +/// A `raise` lexically inside a `try` body (NOT routed through a function call) +/// must be caught by the enclosing `except` in the SAME function. +/// +/// Regression guard: such a raise used to lower to the cross-function error +/// epilogue (`LDI R22,code; SET; RET`), which returned from the function instead +/// of jumping to the local catch dispatcher — the `except` was skipped and `main` +/// executed a stray `RET` off an empty stack. It now lowers to +/// `LDI R22,code; JMP catch` (no SET, no RET): delivered straight to the local +/// dispatcher, T untouched. +/// +[TestFixture] +public class RaiseInTryTests +{ + private SimSession _session = null!; + + [OneTimeSetUp] + public void BuildFirmware() + => _session = new SimSession(PymcuCompiler.BuildFixture("raise-in-try")); + + private ArduinoUnoSimulation FullRun(int maxMs = 5000) + { + var uno = _session.Reset(); + uno.RunUntilSerial(uno.Serial, "DONE\n", maxMs: maxMs); + return uno; + } + + [Test] + public void Boot_PrintsBanner() + { + var uno = _session.Reset(); + uno.RunUntilSerial(uno.Serial, "RT\n", maxMs: 500); + uno.Serial.Should().ContainLine("RT"); + } + + [Test] + public void A_DirectRaise_IsCaughtLocally() + { + var uno = FullRun(); + uno.Serial.Should().ContainLine("A:caught", + "an unconditional raise in the try body must jump to the local catch dispatcher"); + } + + [Test] + public void A_DirectRaise_DeadCodeAfterRaiseNotExecuted() + { + var uno = FullRun(); + uno.Serial.Text.Should().NotContain("A:miss", + "the statement after an unconditional raise is unreachable"); + } + + [Test] + public void B_NoRaise_HappyPathCompletes() + { + var uno = FullRun(); + uno.Serial.Should().ContainLine("B:ok"); + uno.Serial.Text.Should().NotContain("B:miss"); + } + + [Test] + public void C_RaiseNestedInIf_IsCaughtLocally() + { + var uno = FullRun(); + uno.Serial.Should().ContainLine("C:caught", + "a raise nested inside an if inside the try body must still be caught locally"); + uno.Serial.Text.Should().NotContain("C:miss"); + } + + [Test] + public void Reaches_DONE_NoStrayReturn() + { + // If the local raise still emitted a stray RET, main would return off an + // empty stack and never reach DONE. + var uno = FullRun(); + uno.Serial.Should().ContainLine("DONE", + "execution must continue past the try blocks (no stray RET from a local raise)"); + } +} diff --git a/tests/integration/Tests/AVR/SignedDivModTests.cs b/tests/integration/Tests/AVR/SignedDivModTests.cs new file mode 100644 index 00000000..e2bf7704 --- /dev/null +++ b/tests/integration/Tests/AVR/SignedDivModTests.cs @@ -0,0 +1,58 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Exhaustive validation of signed integer floor division/modulo (codegen backlog A38). +/// For each width the generator emits every sign combination of (a, b) plus exact/inexact, +/// zero-dividend, ±1, and min/max edge cases, runs them through the __divs*/__mods* runtime +/// routines on the simulator, and compares against a Python floor-semantics oracle. A wrong +/// sign, a missing floor correction, or a clobbered register shows up as a mismatch. +/// +[TestFixture] +public class SignedDivModTests +{ + [TestCase("int8", 1)] + [TestCase("int16", 2)] + [TestCase("int32", 4)] + public void FloorDivMod_MatchesPythonSemantics(string typeName, int bytes) + { + var prog = SignedDivModProgram.Generate(typeName, bytes); + var hex = PymcuCompiler.BuildSource(prog.Source); + + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(prog.InputByte); + uno.RunUntilSerial(uno.Serial, s => CountNewlines(s) >= prog.Expected.Count + 1, maxMs: 12000); + + var got = ParseSignedAfterBanner(uno.Serial.Text, prog.Expected.Count); + got.Should().Equal(prog.Expected, + $"{typeName}: simulated signed //,% must match Python's floor semantics.\n" + + $"--- serial ---\n{uno.Serial.Text}"); + } + + private static int CountNewlines(string s) + { + int n = 0; + foreach (var c in s) if (c == '\n') n++; + return n; + } + + private static List ParseSignedAfterBanner(string text, int count) + { + var lines = text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var result = new List(); + for (int i = start + 1; i < lines.Length && result.Count < count; i++) + { + var t = lines[i].Trim(); + if (t.Length == 0) continue; + if (long.TryParse(t, out long v)) result.Add(v); + else break; + } + return result; + } +} diff --git a/tests/integration/Tests/AVR/SignedPrintTests.cs b/tests/integration/Tests/AVR/SignedPrintTests.cs new file mode 100644 index 00000000..90b33a6a --- /dev/null +++ b/tests/integration/Tests/AVR/SignedPrintTests.cs @@ -0,0 +1,112 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// print() must honour the static signedness of its argument. A negative int8/int16 +/// has to print with a leading '-', not the unsigned reading of its byte pattern +/// (e.g. -1 must be "-1", not "255"). Two root causes were fixed together: +/// +/// 1. EmitPrintBuiltin had no DataType.INT8 case, so an int8 fell through to the +/// unsigned uart_write_decimal_u8 formatter. It now widens to int16 and uses +/// uart_write_decimal_i16. +/// 2. Copy propagation forwarded through the width-/signedness-changing copy that a +/// numeric cast emits (int8(s) => Copy(uint8 -> int8)), so the printed value +/// reverted to its unsigned source type. The optimizer now treats such a copy as +/// a barrier (ChangesRepr), keeping the cast's type. +/// +/// The seed byte is read at runtime (read_blocking) so nothing constant-folds — the +/// signed widen/format path is actually exercised. +/// +[TestFixture] +public class SignedPrintTests +{ + private static List RunWithSeed(string src, byte seed, int wantLines) + { + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(seed); + uno.RunUntilSerial(uno.Serial, s => s.Replace("\r", "").Split('\n').Length >= wantLines + 2, maxMs: 4000); + + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var outp = new List(); + for (int i = start + 1; i < lines.Length && outp.Count < wantLines; i++) + { + var t = lines[i].Trim(); + if (t.Length > 0) outp.Add(t); + } + return outp; + } + + // int8(0xFF) = -1; (-1) >> 1 = -1. Both must print with the sign. + [Test] + public void PrintInt8_Negative_PrintsSigned() + { + const string src = """ +from pymcu.types import int8, uint8 +from pymcu.hal.uart import UART + + +def main(): + uart = UART(9600) + uart.println("GO") + s: uint8 = uart.read_blocking() + x: int8 = int8(s) + a: int8 = x >> 1 + print(x) + print(a) + while True: + pass +"""; + RunWithSeed(src, 0xFF, 2).Should().Equal(new List { "-1", "-1" }); + } + + // A positive int8 keeps printing normally (no regression on the common path). + [Test] + public void PrintInt8_Positive_PrintsPlain() + { + const string src = """ +from pymcu.types import int8, uint8 +from pymcu.hal.uart import UART + + +def main(): + uart = UART(9600) + uart.println("GO") + s: uint8 = uart.read_blocking() + x: int8 = int8(s) + print(x) + while True: + pass +"""; + RunWithSeed(src, 0x2A, 1).Should().Equal(new List { "42" }); + } + + // int16 negative path (already had a formatter; guards against the optimizer change + // perturbing 16-bit signed printing). + [Test] + public void PrintInt16_Negative_PrintsSigned() + { + const string src = """ +from pymcu.types import int16, uint8 +from pymcu.hal.uart import UART + + +def main(): + uart = UART(9600) + uart.println("GO") + s: uint8 = uart.read_blocking() + x: int16 = int16(s) - 500 + print(x) + while True: + pass +"""; + // 10 - 500 = -490 + RunWithSeed(src, 10, 1).Should().Equal(new List { "-490" }); + } +} diff --git a/tests/integration/Tests/AVR/SignedShiftWideTests.cs b/tests/integration/Tests/AVR/SignedShiftWideTests.cs new file mode 100644 index 00000000..e6fd398b --- /dev/null +++ b/tests/integration/Tests/AVR/SignedShiftWideTests.cs @@ -0,0 +1,65 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Arithmetic right shift of negative int16/int32 by whole-byte amounts (8..31) must sign-extend, +/// not shift in zeros. A whole-byte shift takes a byte-move fast path in codegen that previously +/// cleared the vacated high bytes (logical) regardless of signedness. Each shift is checked +/// against a Python oracle over a runtime-derived negative value. +/// +[TestFixture] +public class SignedShiftWideTests +{ + private static List Run(string typeName, string body, int wantLines) + { + string src = + $"from pymcu.types import {typeName}, uint8\n" + + "from pymcu.hal.uart import UART\n\n\n" + + "def main():\n" + + " uart = UART(9600)\n" + + " uart.println(\"GO\")\n" + + " s: uint8 = uart.read_blocking()\n" + + $" base: {typeName} = {typeName}(s)\n" + + body + + " while True:\n pass\n"; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(0); + uno.RunUntilSerial(uno.Serial, s => s.Replace("\r", "").Split('\n').Length >= wantLines + 2, maxMs: 4000); + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var outp = new List(); + for (int i = start + 1; i < lines.Length && outp.Count < wantLines; i++) + { + var t = lines[i].Trim(); + if (t.Length > 0 && long.TryParse(t, out long v)) outp.Add(v); + } + return outp; + } + + [Test] + public void Int16_ArithmeticRshift_ByteAligned() + { + // v = -5450; arithmetic >> must floor toward -inf. + var got = Run("int16", + " v: int16 = base - 5450\n" + + " print(v >> 8)\n print(v >> 9)\n print(v >> 12)\n print(v >> 15)\n", 4); + got.Should().Equal(new List { -5450 >> 8, -5450 >> 9, -5450 >> 12, -5450 >> 15 }); + } + + [Test] + public void Int32_ArithmeticRshift_ByteAligned() + { + // v = -1000000000; check whole-byte shifts (8,16,24) and intra-byte (15,31). + const long v = -1000000000; + var got = Run("int32", + " v: int32 = base - 1000000000\n" + + " print(v >> 8)\n print(v >> 15)\n print(v >> 16)\n print(v >> 24)\n print(v >> 31)\n", 5); + got.Should().Equal(new List { v >> 8, v >> 15, v >> 16, v >> 24, v >> 31 }); + } +} diff --git a/tests/integration/Tests/AVR/SignedStressTests.cs b/tests/integration/Tests/AVR/SignedStressTests.cs new file mode 100644 index 00000000..e039b24d --- /dev/null +++ b/tests/integration/Tests/AVR/SignedStressTests.cs @@ -0,0 +1,79 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Property/differential validation of signed arithmetic fidelity: each seed generates a +/// register-pressure program over int8/int16 locals using the whole signed operator set +/// (+ - * & | ^, floor // and %, arithmetic >> and <<, and a call-spanning signed +/// helper) and compares the simulated output against a C# fixed-width signed oracle. This is the +/// systematic check that PyMCU's signed semantics match Python (within fixed-width wrapping). +/// +[TestFixture] +public class SignedStressTests +{ + private static readonly int[] Seeds = { 1, 2, 3, 7, 13, 42, 99, 123, 200, 255, 777, 2024 }; + + [TestCaseSource(nameof(Seeds))] + public void GeneratedProgram_MatchesSignedReferenceSemantics(int seed) + { + var prog = SignedStressProgram.Generate(seed); + var hex = PymcuCompiler.BuildSource(prog.Source); + + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(prog.InputByte); + uno.RunUntilSerial(uno.Serial, s => CountNewlines(s) >= prog.Expected.Count + 1, maxMs: 6000); + + var got = ParseSignedAfterBanner(uno.Serial.Text, prog.Expected.Count); + got.Should().Equal(prog.Expected, + $"seed {seed}: simulated signed output must match the fixed-width signed reference.\n" + + $"--- program ---\n{prog.Source}\n--- serial ---\n{uno.Serial.Text}"); + } + + // Heavy sweep, [Explicit] like the other stress sweeps. + [Test, Explicit("heavy: run on demand to validate a signed-codegen change")] + public void Sweep() + { + var failures = new List(); + for (int seed = 1; seed <= 100; seed++) + { + var prog = SignedStressProgram.Generate(seed); + var hex = PymcuCompiler.BuildSource(prog.Source); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(prog.InputByte); + uno.RunUntilSerial(uno.Serial, s => CountNewlines(s) >= prog.Expected.Count + 1, maxMs: 6000); + var got = ParseSignedAfterBanner(uno.Serial.Text, prog.Expected.Count); + if (!got.SequenceEqual(prog.Expected)) + failures.Add($"seed {seed}: expected [{string.Join(",", prog.Expected)}] got [{string.Join(",", got)}]"); + } + failures.Should().BeEmpty($"{failures.Count} seed(s) miscompiled:\n{string.Join("\n", failures)}"); + } + + private static int CountNewlines(string s) + { + int n = 0; + foreach (var c in s) if (c == '\n') n++; + return n; + } + + private static List ParseSignedAfterBanner(string text, int count) + { + var lines = text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var result = new List(); + for (int i = start + 1; i < lines.Length && result.Count < count; i++) + { + var t = lines[i].Trim(); + if (t.Length == 0) continue; + if (int.TryParse(t, out int v)) result.Add(v); + else break; + } + return result; + } +} diff --git a/tests/integration/Tests/AVR/SliceIterTests.cs b/tests/integration/Tests/AVR/SliceIterTests.cs new file mode 100644 index 00000000..7afd7c68 --- /dev/null +++ b/tests/integration/Tests/AVR/SliceIterTests.cs @@ -0,0 +1,62 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Iterating a fixed-array slice in a for-loop (for v in arr[lo:hi:step]) — previously rejected +/// ("for-in iterable must be ..."). The slice unrolls over its index range (constant bounds, +/// negative indices and open ends normalized like elsewhere), binding the loop variable to each +/// element. Runtime-seeded array values so nothing folds to a constant sum. +/// +[TestFixture] +public class SliceIterTests +{ + [Test] + public void SliceForms_SumCorrectly() + { + const string src = """ +from pymcu.types import uint8 +from pymcu.hal.uart import UART + + +def main(): + uart = UART(9600) + uart.println("GO") + s: uint8 = uart.read_blocking() + arr: uint8[5] = [1, 2, 3, 4, 5] + arr[0] = arr[0] + (s - 5) + t: uint8 = 0 + for v in arr[1:4]: + t = t + v + print(t) + u: uint8 = 0 + for v in arr[2:]: + u = u + v + print(u) + w: uint8 = 0 + for v in arr[:2]: + w = w + v + print(w) + x: uint8 = 0 + for v in arr[::2]: + x = x + v + print(x) + while True: + pass +"""; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(5); // arr[0] stays 1 + uno.RunUntilSerial(uno.Serial, t => t.Replace("\r", "").Split('\n').Length >= 6, maxMs: 4000); + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var got = new List(); + for (int i = start + 1; i < lines.Length && got.Count < 4; i++) + if (int.TryParse(lines[i].Trim(), out int v)) got.Add(v); + got.Should().Equal(new List { 9, 12, 3, 9 }); // 2+3+4, 3+4+5, 1+2, 1+3+5 + } +} diff --git a/tests/integration/Tests/AVR/SmoothingTests.cs b/tests/integration/Tests/AVR/SmoothingTests.cs new file mode 100644 index 00000000..2e28cd80 --- /dev/null +++ b/tests/integration/Tests/AVR/SmoothingTests.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; +using Avr8Sharp.TestKit; +using AVR8Sharp.Core.Peripherals; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration tests for examples/smoothing — the Arduino "Smoothing" sketch +/// ported to PyMCU: a 10-sample running average of A0, printed over UART. +/// +/// With a constant analog input the average converges to that input. Choosing +/// inputs above 255 also exercises the uint16 print path end-to-end: a +/// regression to the old uint8-only print() would truncate the result +/// (500 & 0xFF == 244, 300 & 0xFF == 44), so seeing the exact value proves +/// both the filter math and that wide values are not silently narrowed. +/// +[TestFixture] +public class SmoothingTests +{ + private static string _hex = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _hex = PymcuCompiler.Build("smoothing"); + + private static ArduinoUnoSimulation SimWithAdc(double adcCount) + { + var uno = new ArduinoUnoSimulation(); + uno.WithHex(_hex); + uno.AddAdc(AvrAdc.AdcConfig, out var adc); + adc.ChannelValues[0] = adcCount / 1024.0 * 5.0; // count -> voltage at 5 V / 1024 + return uno; + } + + [Test] + public void RunningAverage_ConvergesToConstantInput_500() + { + var uno = SimWithAdc(500); + // Enough output for the 10-sample window to fill and converge to 500. + uno.RunUntilSerialBytes(uno.Serial, 60, maxMs: 5000); + uno.Serial.Should().Contain("500", "the average of a constant 500 input is 500"); + } + + [Test] + public void WideValue_PrintsWithoutEightBitTruncation() + { + var uno = SimWithAdc(300); + uno.RunUntilSerialBytes(uno.Serial, 60, maxMs: 5000); + // The converged average is 300; an 8-bit-truncated print would show 44. + uno.Serial.Should().Contain("300"); + } +} diff --git a/tests/integration/Tests/AVR/SramArrayForEachTests.cs b/tests/integration/Tests/AVR/SramArrayForEachTests.cs new file mode 100644 index 00000000..ac54b4b2 --- /dev/null +++ b/tests/integration/Tests/AVR/SramArrayForEachTests.cs @@ -0,0 +1,60 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// `for v in arr` over an SRAM-resident array (one made runtime-indexed, so it lives in memory +/// rather than as per-element arr__k vars) must read each element with an indexed load. The +/// unrolled forward for-each previously read the missing element vars and summed 0. Verified for +/// uint8 and uint16 element types; a runtime index/read forces the SRAM layout. +/// +[TestFixture] +public class SramArrayForEachTests +{ + private static int NL(string s) { int n = 0; foreach (var c in s) if (c == '\n') n++; return n; } + + [Test] + public void ForEach_OverSramArray_ReadsElements() + { + const string src = """ +from pymcu.types import uint8, uint16 +from pymcu.hal.uart import UART + + +def main(): + uart = UART(9600) + uart.println("GO") + s: uint8 = uart.read_blocking() + b: uint8[3] = [10, 20, 30] + j: uint8 = s - 5 + print(b[j]) + t2: uint8 = 0 + for v in b: + t2 = t2 + v + print(t2) + arr: uint16[4] = [1000, 2000, 3000, 4000] + i: uint8 = s - 3 + print(arr[i]) + tt: uint16 = 0 + for v in arr: + tt = tt + v + print(tt) + while True: + pass +"""; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(5); + uno.RunUntilSerial(uno.Serial, t => NL(t) >= 5, maxMs: 4000); + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var got = new List(); + for (int i = start + 1; i < lines.Length && got.Count < 4; i++) + if (int.TryParse(lines[i].Trim(), out int v)) got.Add(v); + got.Should().Equal(new List { 10, 60, 3000, 10000 }); + } +} diff --git a/tests/integration/Tests/AVR/Ssd1306InitTests.cs b/tests/integration/Tests/AVR/Ssd1306InitTests.cs new file mode 100644 index 00000000..22661f21 --- /dev/null +++ b/tests/integration/Tests/AVR/Ssd1306InitTests.cs @@ -0,0 +1,104 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; +using Avr8Sharp.TestKit; +using AVR8Sharp.Core.Peripherals; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration tests for the SSD1306 OLED driver init sequence (examples/ssd1306). +/// +/// The driver drives its 25-command init from a flash-resident const[uint8[25]] +/// table walked by a runtime loop (pymcu.drivers._ssd1306.i2c._ssd1306_init), +/// instead of 25 inlined sends. These tests pin the observable behaviour: every +/// byte the table emits over I2C, in order, so the table/loop can never silently +/// drift from the datasheet sequence. +/// +/// Wire protocol per command (_ssd1306_cmd): START, SLA+W, 0x00 (control = +/// command stream), cmd, STOP. SLA+W is consumed by ConnectToSlave, so the +/// recorder sees the pair [0x00, cmd] for each of the 25 commands. +/// +[TestFixture] +public class Ssd1306InitTests +{ + // The datasheet 128x64 init sequence the flash table must reproduce. + private static readonly byte[] InitSequence = + { + 0xAE, 0xD5, 0x80, 0xA8, 0x3F, 0xD3, 0x00, 0x40, 0x8D, 0x14, + 0x20, 0x00, 0xA1, 0xC8, 0xDA, 0x12, 0x81, 0xCF, 0xD9, 0xF1, + 0xDB, 0x40, 0xA4, 0xA6, 0xAF, + }; + + private const byte OledAddr = 0x3C; + + private static string _hex = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _hex = PymcuCompiler.Build("ssd1306"); + + /// Boots, records I2C traffic to 0x3C, runs until "OK" (init done). + private static RecordingI2cDevice RunInit() + { + var uno = new ArduinoUnoSimulation(); + uno.WithHex(_hex); + uno.AddTwi(AvrTwi.TwiConfig, out var twi); + var recorder = new RecordingI2cDevice(twi, OledAddr); + twi.EventHandler = recorder; + + // main: println("OLED"), oled.init(), println("OK"). "OK" => init complete. + uno.RunUntilSerial(uno.Serial, "OLED\nOK\n", maxMs: 2000); + return recorder; + } + + [Test] + public void Init_SendsExactly25Commands() + { + var recorder = RunInit(); + // Each command is a [control, cmd] pair on the wire. + recorder.ReceivedBytes.Count.Should().Be(InitSequence.Length * 2, + "25 init commands, each a control byte + command byte"); + } + + [Test] + public void Init_EveryControlByteIsCommandStream() + { + var recorder = RunInit(); + for (int i = 0; i < recorder.ReceivedBytes.Count; i += 2) + recorder.ReceivedBytes[i].Should().Be(0x00, + $"control byte {i / 2} selects the command stream"); + } + + [Test] + public void Init_EmitsDatasheetSequenceInOrder() + { + var recorder = RunInit(); + // Command bytes are the odd indices; they must match the table exactly. + var commands = new List(); + for (int i = 1; i < recorder.ReceivedBytes.Count; i += 2) + commands.Add(recorder.ReceivedBytes[i]); + + commands.Should().Equal(InitSequence, + "the flash table + loop must reproduce the full init sequence in order"); + } + + /// ACKs a specific address and records bytes written to it. + private sealed class RecordingI2cDevice(AvrTwi twi, byte address) : ITwiEventHandler + { + public List ReceivedBytes { get; } = []; + + public void Start(bool repeated) => twi.CompleteStart(); + public void Stop() => twi.CompleteStop(); + + public void ConnectToSlave(byte addr, bool write) => + twi.CompleteConnect(addr == address); + + public void WriteByte(byte data) + { + ReceivedBytes.Add(data); + twi.CompleteWrite(true); + } + + public void ReadByte(bool ack) => twi.CompleteRead(0xFF); + } +} diff --git a/tests/integration/Tests/AVR/TupleReturnAnnTests.cs b/tests/integration/Tests/AVR/TupleReturnAnnTests.cs new file mode 100644 index 00000000..edbcb5a9 --- /dev/null +++ b/tests/integration/Tests/AVR/TupleReturnAnnTests.cs @@ -0,0 +1,55 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// A tuple[T, U] return annotation on an @inline tuple-returning function now parses (the type +/// annotation parser rejected the comma: "Expected ']'"). The annotation is documentation — the +/// caller's unpack targets receive the values. minmax(8, 5) -> (5, 8). +/// +[TestFixture] +public class TupleReturnAnnTests +{ + [Test] + public void TupleAnnotation_ParsesAndReturns() + { + const string src = """ +from pymcu.types import uint8, inline +from pymcu.hal.uart import UART + + +@inline +def minmax(a: uint8, b: uint8) -> tuple[uint8, uint8]: + if a < b: + return a, b + return b, a + + +def main(): + uart = UART(9600) + uart.println("GO") + s: uint8 = uart.read_blocking() + lo: uint8 = 0 + hi: uint8 = 0 + lo, hi = minmax(s + 3, s) + print(lo) + print(hi) + while True: + pass +"""; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(5); + uno.RunUntilSerial(uno.Serial, t => t.Replace("\r", "").Split('\n').Length >= 4, maxMs: 3000); + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var got = new List(); + for (int i = start + 1; i < lines.Length && got.Count < 2; i++) + if (int.TryParse(lines[i].Trim(), out int v)) got.Add(v); + got.Should().Equal(new List { 5, 8 }); + } +} diff --git a/tests/integration/Tests/AVR/TupleSwapTests.cs b/tests/integration/Tests/AVR/TupleSwapTests.cs new file mode 100644 index 00000000..8641b4ad --- /dev/null +++ b/tests/integration/Tests/AVR/TupleSwapTests.cs @@ -0,0 +1,61 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Simultaneous (Python-semantics) tuple assignment with overlap between the targets and the +/// RHS: the whole RHS is evaluated before any target is written, so `a, b = b, a` swaps and +/// `c, d, e = e, c, d` rotates. The snapshots are emitted as named variables so the linear +/// copy-propagation cannot forward an alias past the target's reassignment (which had turned the +/// swap into `a = b; b = b`). Values derive from a runtime seed so nothing constant-folds. +/// +[TestFixture] +public class TupleSwapTests +{ + private static List Run(string body, byte seed, int wantLines) + { + string src = + "from pymcu.types import uint8\n" + + "from pymcu.hal.uart import UART\n\n\n" + + "def main():\n" + + " uart = UART(9600)\n" + + " uart.println(\"GO\")\n" + + " s: uint8 = uart.read_blocking()\n" + + body + + " while True:\n pass\n"; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(seed); + uno.RunUntilSerial(uno.Serial, t => t.Replace("\r", "").Split('\n').Length >= wantLines + 2, maxMs: 4000); + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var outp = new List(); + for (int i = start + 1; i < lines.Length && outp.Count < wantLines; i++) + { + var t = lines[i].Trim(); + if (t.Length > 0 && int.TryParse(t, out int v)) outp.Add(v); + } + return outp; + } + + [Test] + public void Swap_TwoElements() + { + // a=5, b=15 -> a, b = b, a -> a=15, b=5 + Run(" a: uint8 = s\n b: uint8 = s + 10\n a, b = b, a\n print(a)\n print(b)\n", 5, 2) + .Should().Equal(new List { 15, 5 }); + } + + [Test] + public void Rotate_ThreeElements() + { + // c=6, d=7, e=8 -> c, d, e = e, c, d -> c=8, d=6, e=7 + Run(" c: uint8 = s + 1\n d: uint8 = s + 2\n e: uint8 = s + 3\n" + + " c, d, e = e, c, d\n print(c)\n print(d)\n print(e)\n", 5, 3) + .Should().Equal(new List { 8, 6, 7 }); + } +} diff --git a/tests/integration/Tests/AVR/UnrolledBreakContinueTests.cs b/tests/integration/Tests/AVR/UnrolledBreakContinueTests.cs new file mode 100644 index 00000000..8e6160b7 --- /dev/null +++ b/tests/integration/Tests/AVR/UnrolledBreakContinueTests.cs @@ -0,0 +1,147 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// break/continue inside a compile-time-unrolled for-each (over a fixed array, and over an array +/// slice) — previously rejected ("Continue statement outside of loop"). When the body uses +/// break/continue, each unrolled iteration is now bracketed with a per-iteration continue label +/// and a shared break label; loops without them keep the plain unroll. Runtime-seeded so the +/// array's first element is not folded away. +/// +[TestFixture] +public class UnrolledBreakContinueTests +{ + [Test] + public void ContinueAndBreak_InFixedArrayAndSlice() + { + const string src = """ +from pymcu.types import uint8 +from pymcu.hal.uart import UART + + +def main(): + uart = UART(9600) + uart.println("GO") + s: uint8 = uart.read_blocking() + arr: uint8[5] = [10, 20, 30, 40, 50] + arr[0] = arr[0] + (s - 5) + a: uint8 = 0 + for v in arr: + if v == 30: + continue + a = a + v + print(a) + b: uint8 = 0 + for v in arr: + if v == 40: + break + b = b + v + print(b) + c: uint8 = 0 + for v in arr[1:4]: + if v == 30: + continue + c = c + v + print(c) + while True: + pass +"""; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(5); // arr[0] stays 10 + uno.RunUntilSerial(uno.Serial, t => t.Replace("\r", "").Split('\n').Length >= 5, maxMs: 4000); + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var got = new List(); + for (int i = start + 1; i < lines.Length && got.Count < 3; i++) + if (int.TryParse(lines[i].Trim(), out int v)) got.Add(v); + got.Should().Equal(new List { 120, 60, 60 }); // 10+20+40+50, 10+20+30, 20+40 + } + + [Test] + public void ContinueAndBreak_InEnumerateAndReversed() + { + const string src = """ +from pymcu.types import uint8 +from pymcu.hal.uart import UART + + +def main(): + uart = UART(9600) + uart.println("GO") + s: uint8 = uart.read_blocking() + arr: uint8[4] = [10, 20, 30, 40] + arr[0] = arr[0] + (s - 5) + a: uint8 = 0 + for i, v in enumerate(arr): + if v == 30: + continue + a = a + v + print(a) + b: uint8 = 0 + for v in reversed(arr): + if v == 20: + break + b = b + v + print(b) + while True: + pass +"""; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(5); + uno.RunUntilSerial(uno.Serial, t => t.Replace("\r", "").Split('\n').Length >= 4, maxMs: 4000); + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var got = new List(); + for (int i = start + 1; i < lines.Length && got.Count < 2; i++) + if (int.TryParse(lines[i].Trim(), out int v)) got.Add(v); + got.Should().Equal(new List { 70, 70 }); // enumerate skip 30: 10+20+40 ; reversed break at 20: 40+30 + } + + [Test] + public void ContinueAndBreak_InListLiteral() + { + const string src = """ +from pymcu.types import uint8 +from pymcu.hal.uart import UART + + +def main(): + uart = UART(9600) + uart.println("GO") + a: uint8 = 0 + for x in [10, 20, 30, 40]: + if x == 30: + continue + a = a + x + print(a) + b: uint8 = 0 + for x in [10, 20, 30, 40]: + if x == 30: + break + b = b + x + print(b) + while True: + pass +"""; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.RunUntilSerial(uno.Serial, t => t.Replace("\r", "").Split('\n').Length >= 4, maxMs: 3000); + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var got = new List(); + for (int i = start + 1; i < lines.Length && got.Count < 2; i++) + if (int.TryParse(lines[i].Trim(), out int v)) got.Add(v); + got.Should().Equal(new List { 70, 30 }); // continue skip 30: 10+20+40 ; break at 30: 10+20 + } +} diff --git a/tests/integration/Tests/AVR/VolatileFlagTests.cs b/tests/integration/Tests/AVR/VolatileFlagTests.cs new file mode 100644 index 00000000..7002f860 --- /dev/null +++ b/tests/integration/Tests/AVR/VolatileFlagTests.cs @@ -0,0 +1,103 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration tests for fixtures/volatile-flag — ISR-shared plain globals. +/// +/// Two plain uint8 module globals are shared between the INT0 ISR and main with +/// no GPIOR idiom and no manual volatile handling: flag (ISR sets, main +/// polls and clears) and presses (ISR increments, main reads). The core +/// compiler must detect both as ISR-shared (volatile semantics: no constant +/// caching across the store/poll sequence) and the AVR backend must promote +/// them to GPIOR0 (0x3E) and GPIOR1 (0x4A) — verified both behaviorally and by +/// peeking the I/O registers in the simulator. +/// +[TestFixture] +public class VolatileFlagTests +{ + private const int Gpior0 = 0x3E; // 'flag' — most-used global, bit-addressable GPIOR + private const int Gpior1 = 0x4A; // 'presses' + + private SimSession _session = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _session = new SimSession(PymcuCompiler.BuildFixture("volatile-flag")); + + [Test] + public void Boot_SendsBanner() + { + var uno = Sim(); + uno.RunUntilSerial(uno.Serial, "VOLATILE"); + uno.Serial.Should().ContainLine("VOLATILE"); + } + + [Test] + public void PlainGlobalFlag_IsrToMain_DeliversEachPress() + { + var uno = Sim(); + uno.RunUntilSerial(uno.Serial, "VOLATILE\n"); + var before = uno.Serial.ByteCount; + + for (var i = 0; i < 3; i++) + { + Press(uno); + uno.RunUntilSerialBytes(uno.Serial, before + (i + 1) * 2, maxMs: 200); + } + + // presses byte + '\n' per press: 1, 2, 3 — the ISR increments through the + // shared global and main observes every change (volatile poll loop works). + uno.Serial.Bytes[before].Should().Be(0x01, "first press → presses = 1"); + uno.Serial.Bytes[before + 2].Should().Be(0x02, "second press → presses = 2"); + uno.Serial.Bytes[before + 4].Should().Be(0x03, "third press → presses = 3"); + } + + [Test] + public void IsrSharedGlobals_LiveInGpior_NotSram() + { + var uno = Sim(); + uno.RunUntilSerial(uno.Serial, "VOLATILE\n"); + var before = uno.Serial.ByteCount; + + Press(uno); + uno.RunUntilSerialBytes(uno.Serial, before + 2, maxMs: 200); + Press(uno); + uno.RunUntilSerialBytes(uno.Serial, before + 4, maxMs: 200); + + // 'presses' was promoted to GPIOR1: the lifetime counter must be readable + // straight out of the I/O register. + uno.Data[Gpior1].Should().Be(2, "'presses' lives in GPIOR1 (0x4A) after promotion"); + // 'flag' was promoted to GPIOR0 and main already consumed the press. + uno.Data[Gpior0].Should().Be(0, "'flag' lives in GPIOR0 (0x3E) and is cleared after the poll"); + } + + [Test] + public void Backend_EmitsGpiorPromotionMarkers() + { + var asmPath = Path.Combine( + PymcuCompiler.FixtureDir("volatile-flag"), "dist", "debug", "firmware.asm"); + File.Exists(asmPath).Should().BeTrue($"debug asm should exist at {asmPath}"); + + var asm = File.ReadAllText(asmPath); + asm.Should().Contain("volatile 'flag' -> GPIOR @ 0x3E", + "the most-used ISR-shared global gets the bit-addressable GPIOR0"); + asm.Should().Contain("volatile 'presses' -> GPIOR @ 0x4A"); + } + + private ArduinoUnoSimulation Sim() + { + var uno = _session.Reset(); + uno.PortD.SetPinValue(2, true); // button released (INT0 fires on falling edge) + return uno; + } + + private static void Press(ArduinoUnoSimulation uno) + { + uno.PortD.SetPinValue(2, true); + uno.RunMilliseconds(1); + uno.PortD.SetPinValue(2, false); + } +} diff --git a/tests/integration/Tests/AVR/WalrusTests.cs b/tests/integration/Tests/AVR/WalrusTests.cs new file mode 100644 index 00000000..0e36fa06 --- /dev/null +++ b/tests/integration/Tests/AVR/WalrusTests.cs @@ -0,0 +1,51 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// The walrus operator (:=) must persist its assignment to the named variable, not only produce +/// the value for the enclosing expression. The target was stored under the bare name while the +/// body resolved the function-qualified name, so a later read saw 0. Seed-derived; not folded. +/// +[TestFixture] +public class WalrusTests +{ + private static int Newlines(string s) { int n = 0; foreach (var c in s) if (c == '\n') n++; return n; } + + [Test] + public void Walrus_PersistsAndComputes() + { + const string src = """ +from pymcu.types import uint8 +from pymcu.hal.uart import UART + + +def main(): + uart = UART(9600) + uart.println("GO") + s: uint8 = uart.read_blocking() + if (w := s + 7) > 10: + print(w) + print(w) + y: uint8 = (z := s * 2) + 1 + print(z) + print(y) + while True: + pass +"""; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(5); + uno.RunUntilSerial(uno.Serial, t => Newlines(t) >= 5, maxMs: 4000); // GO + 4 values + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var got = new List(); + for (int i = start + 1; i < lines.Length && got.Count < 4; i++) + if (int.TryParse(lines[i].Trim(), out int v)) got.Add(v); + got.Should().Equal(new List { 12, 12, 10, 11 }); + } +} diff --git a/tests/integration/Tests/AVR/WriteBackZcaTests.cs b/tests/integration/Tests/AVR/WriteBackZcaTests.cs new file mode 100644 index 00000000..f1e905a7 --- /dev/null +++ b/tests/integration/Tests/AVR/WriteBackZcaTests.cs @@ -0,0 +1,73 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// RFC 0001 write-back: a single-field (Model A) ZCA whose non-@inline method mutates its field +/// (e.g. def inc(self, by): self.count += by) used to silently lose the mutation, because +/// the field is passed to the shared outlined body BY VALUE. The body now RETURNS the updated +/// field and the call site copies it back to the instance. Validated end-to-end on the simulator: +/// - straight-line chained mutators accumulate, +/// - a (compile-time unrolled) loop of mutators accumulates across iterations, +/// - a void reset mutator zeroes the field. +/// The amount is a runtime UART seed so nothing constant-folds; results compare against an oracle. +/// +[TestFixture] +public class WriteBackZcaTests +{ + private static int NL(string s) { int n = 0; foreach (var c in s) if (c == '\n') n++; return n; } + + [Test] + public void WriteBack_StraightLine_Loop_And_Reset() + { + const string src = + "from pymcu.types import uint8\n" + + "from pymcu.hal.uart import UART\n\n\n" + + "class Counter:\n" + + " def __init__(self):\n" + + " self.count = 0\n\n" + + " def inc(self, by: uint8):\n" + + " self.count = self.count + by\n\n" + + " def reset(self):\n" + + " self.count = 0\n\n" + + " def get(self) -> uint8:\n" + + " return self.count\n\n\n" + + "def main():\n" + + " uart = UART(9600)\n" + + " uart.println(\"GO\")\n" + + " s: uint8 = uart.read_blocking()\n" + + " c = Counter()\n" + + " c.inc(s)\n" + // count = s + " c.inc(s)\n" + // count = 2s + " print(c.get())\n" + // 2s + " d = Counter()\n" + + " for _ in range(4):\n" + + " d.inc(s)\n" + // count = 4s + " print(d.get())\n" + // 4s + " c.reset()\n" + // count = 0 + " c.inc(s)\n" + // count = s + " print(c.get())\n" + // s + " while True:\n pass\n"; + + const int seed = 3; + var expected = new List { 2 * seed, 4 * seed, seed }; + + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(seed); + uno.RunUntilSerial(uno.Serial, t => NL(t) >= 4, maxMs: 6000); + + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + var got = new List(); + for (int i = start + 1; i < lines.Length && got.Count < expected.Count; i++) + if (long.TryParse(lines[i].Trim(), out long v)) got.Add(v); + + got.Should().Equal(expected, + "write-back mutators must persist across chained calls, loop iterations, and a reset"); + } +} diff --git a/tests/integration/Tests/AVR/ZcaArrayTests.cs b/tests/integration/Tests/AVR/ZcaArrayTests.cs new file mode 100644 index 00000000..d8fe2594 --- /dev/null +++ b/tests/integration/Tests/AVR/ZcaArrayTests.cs @@ -0,0 +1,35 @@ +using Avr8Sharp.TestKit.Boards; +using FluentAssertions; +using NUnit.Framework; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration tests for fixtures/zca-array -- RFC 0001 Model B (Class[N]). An array of boxed +/// ZCA instances: N sensors laid out contiguously in SRAM (one 2-byte slot each), all driven by +/// ONE shared Sensor_read through a RUNTIME index. sensors[i] is base + i*stride; sensors[i].read() +/// passes that element address as the self pointer. This is the "multiple DHT" case -- N +/// instances, one method body, a loop. +/// sensors[0]=Sensor(3,4)->12 ; sensors[1]=Sensor(5,7)->35 ; sensors[2]=Sensor(2,9)->18 +/// +[TestFixture] +public class ZcaArrayTests +{ + private static SimSession _session = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _session = new SimSession(PymcuCompiler.BuildFixture("zca-array")); + + [Test] + public void InstanceArray_RuntimeIndex_OneSharedBody() + { + var uno = _session.Reset(); + uno.RunUntilSerialBytes(uno.Serial, 6, maxMs: 500); // "AR\n" (3) + 12 + 35 + 18 + + var bytes = uno.Serial.Bytes; + bytes.Length.Should().BeGreaterThanOrEqualTo(6, "banner 'AR\\n' + three results"); + bytes[^3].Should().Be(12, "sensors[0] = Sensor(3,4); read() = 12"); + bytes[^2].Should().Be(35, "sensors[1] = Sensor(5,7); read() = 35"); + bytes[^1].Should().Be(18, "sensors[2] = Sensor(2,9); read() = 18 (runtime index, shared body)"); + } +} diff --git a/tests/integration/Tests/AVR/ZcaFactoryBTests.cs b/tests/integration/Tests/AVR/ZcaFactoryBTests.cs new file mode 100644 index 00000000..72b2bc6c --- /dev/null +++ b/tests/integration/Tests/AVR/ZcaFactoryBTests.cs @@ -0,0 +1,36 @@ +using Avr8Sharp.TestKit.Boards; +using FluentAssertions; +using NUnit.Framework; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration tests for fixtures/zca-factory-b -- RFC 0001 Model B (register-packed handle). +/// A single-field ZCA has no runtime struct, so a non-@inline factory returns the instance's +/// packed field as a scalar handle in the return register; the use site tracks the result as +/// a handle instance whose @outline method receives that scalar as its self field. This fixes +/// the old `def make() -> Sensor: return Sensor(..)` link error WITHOUT forcing @inline. +/// make_sensor(20) -> handle 21; s.read() = 21*2 = 42 = '*' +/// make_sensor(40) -> handle 41; t.read() = 41*2 = 82 = 'R' +/// Reaching "*R" proves the handle crosses the call boundary at runtime and the shared body +/// computes each instance's result from its own handle. +/// +[TestFixture] +public class ZcaFactoryBTests +{ + private static SimSession _session = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _session = new SimSession(PymcuCompiler.BuildFixture("zca-factory-b")); + + [Test] + public void FactoryHandle_CrossesBoundary_SharedMethodComputesPerInstance() + { + var uno = _session.Reset(); + uno.RunUntilSerial(uno.Serial, s => s.Contains("*R"), maxMs: 400); + uno.Serial.Text.Should().Contain("FB", "boot banner is emitted"); + uno.Serial.Text.Should().Contain("*R", + "factory handles 21 and 41 cross the call boundary; shared Sensor_read doubles " + + "each -> 42 ('*'), 82 ('R')"); + } +} diff --git a/tests/integration/Tests/AVR/ZcaFactorySlotTests.cs b/tests/integration/Tests/AVR/ZcaFactorySlotTests.cs new file mode 100644 index 00000000..2249a84b --- /dev/null +++ b/tests/integration/Tests/AVR/ZcaFactorySlotTests.cs @@ -0,0 +1,35 @@ +using Avr8Sharp.TestKit.Boards; +using FluentAssertions; +using NUnit.Framework; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration tests for fixtures/zca-factory-slot -- RFC 0001 Model B (sret). A non-@inline +/// factory returning a MULTI-field ZCA cannot pack the instance into a return register, so it +/// uses sret: the CALLER allocates the instance's SRAM slot and passes its address as a hidden +/// __self pointer; the factory stores each field through it and returns the pointer. Two factory +/// calls get two distinct slots (no aliasing -- the caller owns each), and the default-outlined +/// method reads fields from its slot via the self pointer. +/// s = make(3,4) -> 3*4 = 12 ; t = make(5,7) -> 5*7 = 35 +/// +[TestFixture] +public class ZcaFactorySlotTests +{ + private static SimSession _session = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _session = new SimSession(PymcuCompiler.BuildFixture("zca-factory-slot")); + + [Test] + public void SretFactory_TwoInstances_DistinctSlots() + { + var uno = _session.Reset(); + uno.RunUntilSerialBytes(uno.Serial, 5, maxMs: 400); // "FS\n" (3) + 12 + 35 + + var bytes = uno.Serial.Bytes; + bytes.Length.Should().BeGreaterThanOrEqualTo(5, "banner 'FS\\n' + two results"); + bytes[^2].Should().Be(12, "s = make(3,4); read() = 3*4 = 12"); + bytes[^1].Should().Be(35, "t = make(5,7); read() = 5*7 = 35 (distinct slot, no aliasing)"); + } +} diff --git a/tests/integration/Tests/AVR/ZcaFactoryTests.cs b/tests/integration/Tests/AVR/ZcaFactoryTests.cs new file mode 100644 index 00000000..65324c24 --- /dev/null +++ b/tests/integration/Tests/AVR/ZcaFactoryTests.cs @@ -0,0 +1,35 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; +using Avr8Sharp.TestKit; +using AVR8Sharp.Core.Peripherals; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration tests for fixtures/zca-factory — an @inline factory that returns a +/// ZCA instance (`make_adc(ch) -> ADC: return ADC(Pin(ch))`). The factory result's +/// methods must inline; `pot = make_adc(14)` then `pot.read()` used to mangle to an +/// undefined flattened symbol and fail at link. With A0 at ADC count 512 the PWM +/// duty is 512 >> 2 == 128, so OCR0A == 128 and the duty byte 128 is echoed. +/// +[TestFixture] +public class ZcaFactoryTests +{ + private const int OCR0A = 0x47; + private static string _hex = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _hex = PymcuCompiler.BuildFixture("zca-factory"); + + [Test] + public void FactoryAdc_ReadsAndDrivesPwm() + { + var uno = new ArduinoUnoSimulation(); + uno.WithHex(_hex); + uno.AddAdc(AvrAdc.AdcConfig, out var adc); + adc.ChannelValues[0] = 512 / 1024.0 * 5.0; // A0 == channel 0 + uno.RunUntilSerialBytes(uno.Serial, 4, maxMs: 4000); + uno.Data[OCR0A].Should().Be(128, "factory ADC read (512) >> 2 = 128 drives OCR0A"); + } +} diff --git a/tests/integration/Tests/AVR/ZcaOutlineDhtTests.cs b/tests/integration/Tests/AVR/ZcaOutlineDhtTests.cs new file mode 100644 index 00000000..dc31061e --- /dev/null +++ b/tests/integration/Tests/AVR/ZcaOutlineDhtTests.cs @@ -0,0 +1,42 @@ +using Avr8Sharp.TestKit.Boards; +using FluentAssertions; +using NUnit.Framework; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration tests for fixtures/zca-outline-dht -- RFC 0001 Model A applied to a +/// DHT-style driver written the NATURAL way (the full bit-bang protocol lives in +/// DHT.read(self), keyed on the runtime field self.pin, with no hand-rolled +/// "thin dispatch + shared worker" split). +/// +/// With @outline the compiler emits ONE DHT_read body shared by all three sensors +/// (pins 2, 3, 4); each instance calls it with its own pin. Flipping to @inline +/// duplicates the entire protocol three times (~3596 B vs ~1268 B here). This test +/// pins the shared-body behaviour: no sensor is attached, so every read() times out +/// on the ACK wait and returns 0xFFFF -> three 0xFF bytes after the "DHT" banner. +/// +[TestFixture] +public class ZcaOutlineDhtTests +{ + private static SimSession _session = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _session = new SimSession(PymcuCompiler.BuildFixture("zca-outline-dht")); + + [Test] + public void Outline_ThreeSensors_ShareOneReadBody() + { + var uno = _session.Reset(); + // Banner "DHT\n" (4 bytes) then three error bytes (0xFF) -- one per sensor, + // all produced by the single shared DHT_read body. + uno.RunUntilSerialBytes(uno.Serial, 7, maxMs: 4000); + + var bytes = uno.Serial.Bytes; + bytes.Length.Should().BeGreaterThanOrEqualTo(7, "banner 'DHT\\n' (4) + three result bytes"); + // The last three bytes are the low byte of each read() == 0xFF (no sensor). + bytes[^1].Should().Be(0xFF, "sensor c read() timed out -> 0xFFFF"); + bytes[^2].Should().Be(0xFF, "sensor b read() timed out -> 0xFFFF"); + bytes[^3].Should().Be(0xFF, "sensor a read() timed out -> 0xFFFF"); + } +} diff --git a/tests/integration/Tests/AVR/ZcaOutlineSelfCallTests.cs b/tests/integration/Tests/AVR/ZcaOutlineSelfCallTests.cs new file mode 100644 index 00000000..ce8920ba --- /dev/null +++ b/tests/integration/Tests/AVR/ZcaOutlineSelfCallTests.cs @@ -0,0 +1,51 @@ +using Avr8Sharp.TestKit.Boards; +using FluentAssertions; +using NUnit.Framework; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration tests for fixtures/zca-outline-selfcall -- RFC 0001 phase 2: +/// an outlined ZCA method that calls a SIBLING method on self. +/// +/// Dev.compute() calls self.helper() twice. It used to be force-inlined per call +/// site (it touches self via a method call, not just a field); now it is outlined +/// once and forwards its own self to the shared Dev_helper body. +/// a = Dev(3): compute(1) = helper(1)+helper(2) = 4+5 = 9 +/// b = Dev(5): compute(2) = helper(2)+helper(3) = 7+8 = 15 +/// Seeing bytes 9 and 15 proves compute forwards each instance's runtime base into +/// the shared helper. The asm assertion proves there is exactly one shared body for +/// each method (no per-call-site / per-instance duplication). +/// +[TestFixture] +public class ZcaOutlineSelfCallTests +{ + private static SimSession _session = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _session = new SimSession(PymcuCompiler.BuildFixture("zca-outline-selfcall")); + + [Test] + public void OutlinedMethod_CallingSibling_ForwardsSelfCorrectly() + { + var uno = _session.Reset(); + uno.RunUntilSerialBytes(uno.Serial, 5, maxMs: 400); // "SC\n" (3) + 9 + 15 + + var bytes = uno.Serial.Bytes; + bytes.Length.Should().BeGreaterThanOrEqualTo(5, "banner 'SC\\n' + two result bytes"); + bytes[^2].Should().Be(9, "Dev(3).compute(1) = (3+1)+(3+2) = 9"); + bytes[^1].Should().Be(15, "Dev(5).compute(2) = (5+2)+(5+3) = 15"); + } + + [Test] + public void BothMethods_AreSharedSubroutines_NotDuplicated() + { + var asm = File.ReadAllText(Path.Combine( + PymcuCompiler.FixtureDir("zca-outline-selfcall"), "dist", "debug", "firmware.asm")); + CountLabel(asm, "Dev_compute").Should().Be(1, "compute is outlined once, not inlined per call site"); + CountLabel(asm, "Dev_helper").Should().Be(1, "helper is outlined once and shared by compute"); + } + + private static int CountLabel(string asm, string label) => + asm.Split('\n').Count(l => l.TrimStart().StartsWith(label + ":")); +} diff --git a/tests/integration/Tests/AVR/ZcaOutlineTests.cs b/tests/integration/Tests/AVR/ZcaOutlineTests.cs new file mode 100644 index 00000000..a6dccf23 --- /dev/null +++ b/tests/integration/Tests/AVR/ZcaOutlineTests.cs @@ -0,0 +1,36 @@ +using Avr8Sharp.TestKit.Boards; +using FluentAssertions; +using NUnit.Framework; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration tests for fixtures/zca-outline -- RFC 0001 Model A (@outline). +/// A ZCA method marked @outline is compiled ONCE as a shared subroutine that takes +/// the instance's runtime fields as leading parameters (self.base -> self_base param), +/// instead of being force-inlined per call site. Two Counter instances (base 65 and 97) +/// drive the SAME Counter_stepped body with different runtime args: +/// a.stepped(1) = 65 + 1 = 66 = 'B' +/// b.stepped(2) = 97 + 2 = 99 = 'c' +/// Reaching "OLBc" over UART proves the outlined call passes each instance's field +/// value correctly and the shared body computes the right result per call. +/// +[TestFixture] +public class ZcaOutlineTests +{ + private static SimSession _session = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _session = new SimSession(PymcuCompiler.BuildFixture("zca-outline")); + + [Test] + public void Outline_TwoInstances_ShareBodyWithRuntimeFields() + { + var uno = _session.Reset(); + uno.RunUntilSerial(uno.Serial, s => s.Contains("Bc"), maxMs: 400); + uno.Serial.Text.Should().Contain("OL", "boot banner is emitted"); + uno.Serial.Text.Should().Contain("Bc", + "outlined Counter_stepped receives each instance's runtime base (65, 97) " + + "and adds k (1, 2) -> 'B','c'"); + } +} diff --git a/tests/integration/Tests/AVR/ZcaSlotTests.cs b/tests/integration/Tests/AVR/ZcaSlotTests.cs new file mode 100644 index 00000000..20a340f7 --- /dev/null +++ b/tests/integration/Tests/AVR/ZcaSlotTests.cs @@ -0,0 +1,37 @@ +using Avr8Sharp.TestKit.Boards; +using FluentAssertions; +using NUnit.Framework; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// Integration tests for fixtures/zca-slot -- RFC 0001 Model B (SRAM slot). A ZCA with >= 2 +/// fields cannot pack into a return register, so it is boxed: its fields live in a fixed SRAM +/// slot and its @outline method takes a `self` pointer, reading each field via BytearrayLoad +/// at a byte offset. Two instances get two distinct 2-byte slots; one shared Sensor_read body +/// walks whichever slot pointer it is handed. +/// a = Sensor(3,4) -> 3*4 = 12 +/// b = Sensor(5,7) -> 5*7 = 35 +/// Distinct results (12, 35) from one shared body prove per-instance state lives in the slot, +/// not baked into duplicated code. +/// +[TestFixture] +public class ZcaSlotTests +{ + private static SimSession _session = null!; + + [OneTimeSetUp] + public void BuildFirmware() => _session = new SimSession(PymcuCompiler.BuildFixture("zca-slot")); + + [Test] + public void Slot_TwoInstances_DistinctStateOneBody() + { + var uno = _session.Reset(); + uno.RunUntilSerialBytes(uno.Serial, 5, maxMs: 400); // "SL\n" (3) + 12 + 35 + + var bytes = uno.Serial.Bytes; + bytes.Length.Should().BeGreaterThanOrEqualTo(5, "banner 'SL\\n' + two results"); + bytes[^2].Should().Be(12, "a = Sensor(3,4); read() = 3*4 = 12"); + bytes[^1].Should().Be(35, "b = Sensor(5,7); read() = 5*7 = 35"); + } +} diff --git a/tests/integration/Tests/AVR/ZipArraysTests.cs b/tests/integration/Tests/AVR/ZipArraysTests.cs new file mode 100644 index 00000000..1fde40e6 --- /dev/null +++ b/tests/integration/Tests/AVR/ZipArraysTests.cs @@ -0,0 +1,47 @@ +using FluentAssertions; +using NUnit.Framework; +using Avr8Sharp.TestKit.Boards; + +namespace PyMCU.IntegrationTests.Tests.AVR; + +/// +/// zip(a, b) over two fixed arrays whose elements are runtime values — previously rejected +/// ("zip() array elements must be compile-time integer constants"). Now iterates element-wise, +/// binding each loop variable to the element (Copy / ArrayLoad). Seed makes a[0] runtime. +/// +[TestFixture] +public class ZipArraysTests +{ + [Test] + public void ZipTwoRuntimeArrays_SumsProducts() + { + const string src = """ +from pymcu.types import uint8 +from pymcu.hal.uart import UART + + +def main(): + uart = UART(9600) + uart.println("GO") + s: uint8 = uart.read_blocking() + a: uint8[3] = [1, 2, 3] + b: uint8[3] = [10, 20, 30] + a[0] = a[0] + (s - 5) + acc: uint8 = 0 + for x, y in zip(a, b): + acc = acc + x * y + print(acc) + while True: + pass +"""; + var hex = PymcuCompiler.BuildSource(src); + var uno = new ArduinoUnoSimulation(); + uno.WithHex(hex); + uno.RunUntilSerial(uno.Serial, "GO\n", maxMs: 500); + uno.Serial.InjectByte(5); // a[0] stays 1 + uno.RunUntilSerial(uno.Serial, t => t.Replace("\r", "").Split('\n').Length >= 3, maxMs: 3000); + var lines = uno.Serial.Text.Replace("\r", "").Split('\n'); + int start = Array.FindIndex(lines, l => l.Trim() == "GO"); + lines[start + 1].Trim().Should().Be("140"); // 1*10 + 2*20 + 3*30 + } +} diff --git a/tests/integration/TwoIsrGenerator.cs b/tests/integration/TwoIsrGenerator.cs new file mode 100644 index 00000000..19eb945e --- /dev/null +++ b/tests/integration/TwoIsrGenerator.cs @@ -0,0 +1,98 @@ +namespace PyMCU.IntegrationTests; + +/// +/// Like but with TWO interrupt handlers (Timer0 and Timer2 +/// overflow), each with its own locals, both firing while main runs a register-pressure loop. +/// Validates that the stack allocator gives each ISR a region disjoint from main AND from the +/// other ISR (the per-ISR base increment, codegen backlog A34): an overlap of the two ISRs' +/// slots, or of either with main's, would perturb main's deterministic printed values. +/// +public sealed class TwoIsrProgram +{ + public string Source { get; } + public byte InputByte { get; } + public IReadOnlyList Expected { get; } + + private TwoIsrProgram(string source, byte input, List expected) + => (Source, InputByte, Expected) = (source, input, expected); + + private const int NumU8 = 8; + private const int OpsPerIter = 12; + private const int Reps = 40; + + public static TwoIsrProgram Generate(int seed) + { + var rng = new Random(seed); + byte input = (byte)(seed * 61 + 17); + var src = new System.Text.StringBuilder(); + + int s = input; + var v = new int[NumU8]; + var initOp = new string[NumU8]; var initC = new int[NumU8]; + string[] ops = { "+", "-", "*", "&", "|", "^" }; + for (int i = 0; i < NumU8; i++) { initOp[i] = ops[rng.Next(ops.Length)]; initC[i] = rng.Next(0, 256); v[i] = ApplyU8(s, initOp[i], initC[i]); } + + var stmts = new List<(string Src, Action Apply)>(); + for (int n = 0; n < OpsPerIter; n++) + { + int k = rng.Next(NumU8), a = rng.Next(NumU8); + if (rng.Next(2) == 0) { int b = rng.Next(NumU8); string op = ops[rng.Next(ops.Length)]; stmts.Add(($"v{k} = v{a} {op} v{b}", () => v[k] = ApplyU8(v[a], op, v[b]))); } + else { int c = rng.Next(0, 256); string op = ops[rng.Next(ops.Length)]; stmts.Add(($"v{k} = v{a} {op} {c}", () => v[k] = ApplyU8(v[a], op, c))); } + } + + int a0 = rng.Next(0, 256), b0 = rng.Next(0, 256); + int a1 = rng.Next(0, 256), b1 = rng.Next(0, 256); + + src.Append("from pymcu.types import uint8, interrupt, asm\n"); + src.Append("from pymcu.chips.atmega328p import TCCR0B, TIMSK0, TCCR2B, TIMSK2\n"); + src.Append("from pymcu.hal.uart import UART\n\n\n"); + src.Append("acc0: uint8 = 0\nacc1: uint8 = 0\n\n\n"); + // Timer0 overflow → vector 0x0020. + src.Append("@interrupt(0x0020)\n"); + src.Append("def t0_ovf():\n"); + src.Append(" global acc0\n"); + src.Append($" p0: uint8 = acc0 + {a0}\n"); + src.Append($" p1: uint8 = p0 * 3\n"); + src.Append($" acc0 = p1 ^ {b0}\n\n\n"); + // Timer2 overflow → vector 0x0012. + src.Append("@interrupt(0x0012)\n"); + src.Append("def t2_ovf():\n"); + src.Append(" global acc1\n"); + src.Append($" q0: uint8 = acc1 ^ {a1}\n"); + src.Append($" q1: uint8 = q0 + 7\n"); + src.Append($" acc1 = q1 * 5\n\n\n"); + + src.Append("def main():\n"); + src.Append(" uart = UART(9600)\n"); + src.Append(" uart.println(\"GO\")\n"); + src.Append(" TCCR0B.value = 0x01\n TIMSK0.value = 0x01\n"); // Timer0 prescaler 1, TOIE0 + src.Append(" TCCR2B.value = 0x01\n TIMSK2.value = 0x01\n"); // Timer2 prescaler 1, TOIE2 + src.Append(" asm(\"SEI\")\n"); + src.Append(" s: uint8 = uart.read_blocking()\n"); + for (int i = 0; i < NumU8; i++) src.Append($" v{i}: uint8 = s {initOp[i]} {initC[i]}\n"); + src.Append(" r: uint8 = 0\n"); + src.Append($" while r < {Reps}:\n"); + foreach (var (line, _) in stmts) src.Append($" {line}\n"); + src.Append(" r = r + 1\n"); + + for (int rep = 0; rep < Reps; rep++) + foreach (var (_, apply) in stmts) apply(); + + var expected = new List(); + for (int i = 0; i < NumU8; i++) { src.Append($" print(v{i})\n"); expected.Add(v[i]); } + src.Append(" while True:\n pass\n"); + + return new TwoIsrProgram(src.ToString(), input, expected); + } + + private static int ApplyU8(int a, string op, int b) => op switch + { + "+" => (a + b) & 0xFF, + "-" => (a - b) & 0xFF, + "*" => (a * b) & 0xFF, + "&" => (a & b) & 0xFF, + "|" => (a | b) & 0xFF, + "^" => (a ^ b) & 0xFF, + _ => throw new ArgumentException(op), + }; +} diff --git a/tests/integration/fixtures/div16-correctness/src/main.py b/tests/integration/fixtures/div16-correctness/src/main.py index dc4d1ee1..92cc5965 100644 --- a/tests/integration/fixtures/div16-correctness/src/main.py +++ b/tests/integration/fixtures/div16-correctness/src/main.py @@ -25,7 +25,7 @@ def main(): # Test 1 / Test 2: 1000 / 10, 1000 % 10 a: uint16 = 1000 b: uint16 = 10 - q1: uint16 = a / b + q1: uint16 = a // b r1: uint16 = a % b GPIOR0.value = uint8(q1 & 0xFF) # 100 GPIOR1.value = uint8((q1 >> 8) & 0xFF) # 0 @@ -34,7 +34,7 @@ def main(): # Test 3 / Test 4: 65000 / 256, 65000 % 256 (large operands > 255) c: uint16 = 65000 d: uint16 = 256 - q2: uint16 = c / d + q2: uint16 = c // d r2: uint16 = c % d OCR0A.value = uint8(q2 & 0xFF) # 253 OCR0B.value = uint8((q2 >> 8) & 0xFF) # 0 diff --git a/tests/integration/fixtures/div32-correctness/src/main.py b/tests/integration/fixtures/div32-correctness/src/main.py index 60d442ff..7f5d8615 100644 --- a/tests/integration/fixtures/div32-correctness/src/main.py +++ b/tests/integration/fixtures/div32-correctness/src/main.py @@ -25,7 +25,7 @@ def main(): # Test 1 / Test 2: 100000 / 1000, 100000 % 1000 a: uint32 = 100000 b: uint32 = 1000 - q1: uint32 = a / b + q1: uint32 = a // b r1: uint32 = a % b GPIOR0.value = uint8(q1 & 0xFF) # 100 GPIOR1.value = uint8((q1 >> 8) & 0xFF) # 0 @@ -34,7 +34,7 @@ def main(): # Test 3 / Test 4: 1000000 / 300, 1000000 % 300 c: uint32 = 1000000 d: uint32 = 300 - q2: uint32 = c / d + q2: uint32 = c // d r2: uint32 = c % d OCR0A.value = uint8(q2 & 0xFF) # 3333 & 0xFF = 5 OCR0B.value = uint8((q2 >> 8) & 0xFF) # (3333 >> 8) & 0xFF = 13 diff --git a/tests/integration/fixtures/divmod16/pyproject.toml b/tests/integration/fixtures/divmod16/pyproject.toml new file mode 100644 index 00000000..801f6a44 --- /dev/null +++ b/tests/integration/fixtures/divmod16/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "divmod16" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "pymcu-stdlib>=0.1.2a5", + "pymcu>=0.1.0a27" +] + +[tool.pymcu] +target = "atmega328p" +frequency = 16000000 +sources = "src" +entry = "main.py" diff --git a/tests/integration/fixtures/divmod16/src/main.py b/tests/integration/fixtures/divmod16/src/main.py new file mode 100644 index 00000000..70fae4da --- /dev/null +++ b/tests/integration/fixtures/divmod16/src/main.py @@ -0,0 +1,26 @@ +# PyMCU -- divmod16: the divmod() built-in at 16-bit width. +# +# Verifies that divmod() produces 16-bit results. The earlier implementation +# hard-coded uint8 result variables (and __div8/__mod8), so a wide quotient was +# silently narrowed: 3000 // 10 = 300 became 300 & 0xFF == 44. +# +# UART output (9600 baud, print() appends its own newline): +# "DM\n" -- boot banner +# "300\n" -- 3000 // 10 = 300 (> 255: proves the quotient keeps 16 bits) +# "0\n" -- 3000 % 10 = 0 +from pymcu.types import uint16 +from pymcu.hal.uart import UART + + +def main(): + uart = UART(9600) + uart.println("DM") + + q: uint16 = 0 + r: uint16 = 0 + q, r = divmod(3000, 10) + print(q) + print(r) + + while True: + pass diff --git a/tests/integration/fixtures/dsp-stress/pyproject.toml b/tests/integration/fixtures/dsp-stress/pyproject.toml new file mode 100644 index 00000000..8ba4bd4a --- /dev/null +++ b/tests/integration/fixtures/dsp-stress/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "dsp-stress" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = ["pymcu-stdlib>=0.1.2a5", "pymcu>=0.1.0a27"] + +[tool.pymcu] +target = "atmega328p" +frequency = 16000000 +sources = "src" +entry = "main.py" diff --git a/tests/integration/fixtures/dsp-stress/src/main.py b/tests/integration/fixtures/dsp-stress/src/main.py new file mode 100644 index 00000000..a8461611 --- /dev/null +++ b/tests/integration/fixtures/dsp-stress/src/main.py @@ -0,0 +1,74 @@ +# Massive stress: 4-channel ADC DSP exercising every AVR codegen optimization +# at once -- byte-pack (4 sensor reads), Z-CSE (sliding windows), 16-bit temp +# allocation (heavy uint16 math), divmod fusion (//, decimal print) and the +# divmod() builtin. +from pymcu.types import uint8, uint16 +from pymcu.hal.uart import UART +from pymcu.hal.adc import AnalogPin + + +def main(): + uart = UART(9600) + uart.println("DSP") + ch0 = AnalogPin("PC0") + ch1 = AnalogPin("PC1") + ch2 = AnalogPin("PC2") + ch3 = AnalogPin("PC3") + + buf0: uint16[8] = [0, 0, 0, 0, 0, 0, 0, 0] + buf1: uint16[8] = [0, 0, 0, 0, 0, 0, 0, 0] + buf2: uint16[8] = [0, 0, 0, 0, 0, 0, 0, 0] + buf3: uint16[8] = [0, 0, 0, 0, 0, 0, 0, 0] + idx: uint8 = 0 + tot0: uint16 = 0 + tot1: uint16 = 0 + tot2: uint16 = 0 + tot3: uint16 = 0 + vmin: uint16 = 65535 + vmax: uint16 = 0 + + while True: + s0: uint16 = ch0.read() + s1: uint16 = ch1.read() + s2: uint16 = ch2.read() + s3: uint16 = ch3.read() + + tot0 = tot0 - buf0[idx] + buf0[idx] = s0 + tot0 = tot0 + buf0[idx] + tot1 = tot1 - buf1[idx] + buf1[idx] = s1 + tot1 = tot1 + buf1[idx] + tot2 = tot2 - buf2[idx] + buf2[idx] = s2 + tot2 = tot2 + buf2[idx] + tot3 = tot3 - buf3[idx] + buf3[idx] = s3 + tot3 = tot3 + buf3[idx] + + idx = idx + 1 + if idx >= 8: + idx = 0 + + a0: uint16 = tot0 // 8 + a1: uint16 = tot1 // 8 + a2: uint16 = tot2 // 8 + a3: uint16 = tot3 // 8 + + if a0 < vmin: + vmin = a0 + if a0 > vmax: + vmax = a0 + + q: uint16 = 0 + r: uint16 = 0 + q, r = divmod(a0, 10) + + print(a0) + print(a1) + print(a2) + print(a3) + print(q) + print(r) + print(vmin) + print(vmax) diff --git a/tests/integration/fixtures/global-init/pyproject.toml b/tests/integration/fixtures/global-init/pyproject.toml new file mode 100644 index 00000000..9e1852e4 --- /dev/null +++ b/tests/integration/fixtures/global-init/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "global-init" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = ["pymcu-stdlib>=0.1.2a5", "pymcu>=0.1.0a27"] + +[tool.pymcu] +target = "atmega328p" +frequency = 16000000 +sources = "src" +entry = "main.py" diff --git a/tests/integration/fixtures/global-init/src/main.py b/tests/integration/fixtures/global-init/src/main.py new file mode 100644 index 00000000..37a87620 --- /dev/null +++ b/tests/integration/fixtures/global-init/src/main.py @@ -0,0 +1,26 @@ +# Module-level mutable global with a non-zero initializer must hold that value at +# startup. It used to land in BSS (zeroed by crt0) with the initializer dropped, +# so it silently read 0; the fix injects the init into main(). +# +# A second global is mutated at runtime to prove the value is a real RAM cell, +# not a folded compile-time constant. +# "3007\n" -- _seed initialized to 3007 +# "3008\n" -- _seed + 1 after a runtime increment +from pymcu.types import uint16 +from pymcu.hal.uart import UART + +_seed: uint16 = 3007 + + +def main(): + uart = UART(9600) + print(_seed) + bump() + print(_seed) + while True: + pass + + +def bump(): + global _seed + _seed = _seed + 1 diff --git a/tests/integration/fixtures/module-alias/pyproject.toml b/tests/integration/fixtures/module-alias/pyproject.toml new file mode 100644 index 00000000..667a0621 --- /dev/null +++ b/tests/integration/fixtures/module-alias/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "module-alias" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = ["pymcu-stdlib>=0.1.0", "pymcu-micropython>=0.1.0a1"] + +[tool.pymcu] +board = "arduino_uno" +frequency = 16000000 +sources = "src" +entry = "main.py" +stdlib = ["micropython"] diff --git a/tests/integration/fixtures/module-alias/src/main.py b/tests/integration/fixtures/module-alias/src/main.py new file mode 100644 index 00000000..0a99f928 --- /dev/null +++ b/tests/integration/fixtures/module-alias/src/main.py @@ -0,0 +1,16 @@ +# Aliased module imports must resolve to the real module: `import machine as m` +# and `import time as t` used to mangle m.UART / t.sleep_ms to undefined symbols. +# Constructs a UART through the alias and writes a banner; the comma-separated +# import exercises multi-module import parsing too. +import machine as m, time as t + + +def main(): + uart = m.UART(0, 9600) + led = m.Pin(13, m.Pin.OUT) + uart.write("MA\n") + led.value(1) + t.sleep_ms(1) + led.value(0) + while True: + pass diff --git a/tests/integration/fixtures/ptr-offset/pyproject.toml b/tests/integration/fixtures/ptr-offset/pyproject.toml new file mode 100644 index 00000000..85b04f5c --- /dev/null +++ b/tests/integration/fixtures/ptr-offset/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "ptr-offset" +version = "0.1.0" +dependencies = ["pymcu-stdlib"] + +[tool.pymcu] +target = "atmega328p" +frequency = 16000000 +sources = "src" +entry = "main.py" diff --git a/tests/integration/fixtures/ptr-offset/src/main.py b/tests/integration/fixtures/ptr-offset/src/main.py new file mode 100644 index 00000000..2f5778ee --- /dev/null +++ b/tests/integration/fixtures/ptr-offset/src/main.py @@ -0,0 +1,13 @@ +# ptr(BASE + offset): compile-time address arithmetic on a register base. +# A bare register name contributes its ADDRESS (not its dereferenced value), so +# PINB(0x23) + 2 resolves to PORTB(0x25). Driving that pointer must set PORTB. +from pymcu.types import uint8, ptr +from pymcu.chips.atmega328p import DDRB, PINB + + +def main(): + DDRB.value = 0xFF # all PORTB pins outputs + port: ptr[uint8] = ptr(PINB + 2) # 0x23 + 2 = 0x25 = PORTB + port.value = 0xFF # drive PORTB high via the computed pointer + while True: + pass diff --git a/tests/integration/fixtures/ptr-runtime/pyproject.toml b/tests/integration/fixtures/ptr-runtime/pyproject.toml new file mode 100644 index 00000000..60703cee --- /dev/null +++ b/tests/integration/fixtures/ptr-runtime/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "ptr-runtime" +version = "0.1.0" +dependencies = ["pymcu-stdlib"] + +[tool.pymcu] +target = "atmega328p" +frequency = 16000000 +sources = "src" +entry = "main.py" diff --git a/tests/integration/fixtures/ptr-runtime/src/main.py b/tests/integration/fixtures/ptr-runtime/src/main.py new file mode 100644 index 00000000..827d0456 --- /dev/null +++ b/tests/integration/fixtures/ptr-runtime/src/main.py @@ -0,0 +1,24 @@ +# ptr(BASE + offset) with a runtime offset, exercised through .value: +# write, augmented-assign (read-modify-write), and read all lower to Store/Load +# indirect through the computed address. Round-trips a value via a free SRAM slot. +from pymcu.types import uint8, uint16, ptr, const +from pymcu.hal.uart import UART + +BASE: const[uint16] = 0x0500 # free SRAM on ATmega328P (0x0100..0x08FF) + + +def main(): + uart = UART(9600) + uart.println("PR") + + off: uint8 = 4 + slot: ptr[uint8] = ptr(BASE + off) # runtime-offset pointer (via variable) + slot.value = 40 # StoreIndirect 40 + slot.value += 2 # LoadIndirect + 2 + StoreIndirect -> 42 + + # Inline form: read back through a freshly computed pointer to the same address. + result: uint8 = ptr(BASE + off).value + uart.write(result) # expect 42 + + while True: + pass diff --git a/tests/integration/fixtures/raise-in-try/pyproject.toml b/tests/integration/fixtures/raise-in-try/pyproject.toml new file mode 100644 index 00000000..0dd7a6c6 --- /dev/null +++ b/tests/integration/fixtures/raise-in-try/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "raise-in-try" +version = "0.1.0" +dependencies = ["pymcu-stdlib"] + +[tool.pymcu] +target = "atmega328p" +frequency = 16000000 +sources = "src" +entry = "main.py" diff --git a/tests/integration/fixtures/raise-in-try/src/main.py b/tests/integration/fixtures/raise-in-try/src/main.py new file mode 100644 index 00000000..8f9f1dde --- /dev/null +++ b/tests/integration/fixtures/raise-in-try/src/main.py @@ -0,0 +1,51 @@ +# raise-in-try: a `raise` lexically inside a `try` body (NOT via a function call) +# must be caught by the enclosing `except` in the SAME function. +# +# Before the fix, such a raise lowered to `SET; RET` (the cross-function error +# epilogue): it returned from the function instead of jumping to the local catch, +# so the except was skipped AND main RET'd off an empty stack. Now it lowers to +# `LDI R22,code; JMP catch` (no SET, no RET) and is caught locally. +# +# Expected UART output: +# RT +# A:caught (direct raise caught locally) +# B:ok (no raise -> happy path) +# C:caught (raise inside a nested if, still in the try body) +# DONE +from pymcu.types import uint8 +from pymcu.hal.uart import UART +from pymcu.exceptions import ValueError + + +def main(): + uart = UART(9600) + uart.println("RT") + + flag: uint8 = 0 + + # A: unconditional direct raise in the try body + try: + raise ValueError + uart.println("A:miss") + except ValueError: + uart.println("A:caught") + + # B: no raise -> the try body completes, except not triggered + try: + if flag == 1: + raise ValueError + uart.println("B:ok") + except ValueError: + uart.println("B:miss") + + # C: raise nested inside an if inside the try body + try: + if flag == 0: + raise ValueError + uart.println("C:miss") + except ValueError: + uart.println("C:caught") + + uart.println("DONE") + while True: + pass diff --git a/tests/integration/fixtures/sreg-flags/src/main.py b/tests/integration/fixtures/sreg-flags/src/main.py index 553b531a..29783dda 100644 --- a/tests/integration/fixtures/sreg-flags/src/main.py +++ b/tests/integration/fixtures/sreg-flags/src/main.py @@ -22,13 +22,14 @@ def add_u8(a: uint8, b: uint8) -> uint8: - """Return a+b. Both params are runtime: forces ADD Rd,Rr to be emitted.""" - return a + b + """Return a+b as fixed-width 8-bit. The explicit uint8(...) cast opts out of arithmetic + promotion so a real 8-bit ADD Rd,Rr is emitted (and its 8-bit SREG flags are observable).""" + return uint8(a + b) def sub_u8(a: uint8, b: uint8) -> uint8: - """Return a-b. Both params are runtime: forces SUB Rd,Rr to be emitted.""" - return a - b + """Return a-b as fixed-width 8-bit (explicit cast -> 8-bit SUB Rd,Rr, 8-bit flags).""" + return uint8(a - b) def main(): diff --git a/tests/integration/fixtures/volatile-flag/pyproject.toml b/tests/integration/fixtures/volatile-flag/pyproject.toml new file mode 100644 index 00000000..cebd5fab --- /dev/null +++ b/tests/integration/fixtures/volatile-flag/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "volatile-flag" +version = "0.1.0" +dependencies = ["pymcu-stdlib"] + +[tool.pymcu] +target = "atmega328p" +frequency = 16000000 +sources = "src" +entry = "main.py" diff --git a/tests/integration/fixtures/volatile-flag/src/main.py b/tests/integration/fixtures/volatile-flag/src/main.py new file mode 100644 index 00000000..62fb40ee --- /dev/null +++ b/tests/integration/fixtures/volatile-flag/src/main.py @@ -0,0 +1,42 @@ +# ATmega328P: ISR-shared plain globals — volatile semantics + GPIOR promotion +# +# Two plain uint8 module globals shared between the INT0 ISR and main: +# flag — set by the ISR, polled and cleared by main (classic ISR flag) +# presses — incremented by the ISR, only read by main (lifetime counter) +# +# No GPIOR0[*] idiom, no @interrupt decorator, no manual volatile handling: +# the compiler detects both globals as ISR-shared (isrSharedGlobals in the +# .mir) and the AVR backend promotes them to GPIOR0/GPIOR1, so the ISR and +# the polling loop talk through 1-cycle IN/OUT I/O registers instead of SRAM. +# +# UART output (9600 baud): +# Boot: "VOLATILE\n" +# Each press: presses byte (1, 2, 3...) + '\n' +# +from pymcu.types import uint8 +from pymcu.hal.gpio import Pin +from pymcu.hal.uart import UART + +flag: uint8 = 0 +presses: uint8 = 0 + + +def on_press(): + global flag, presses + flag = 1 + presses = presses + 1 + + +def main(): + btn = Pin("PD2", Pin.IN, pull=Pin.PULL_UP) + uart = UART(9600) + + btn.irq(Pin.IRQ_FALLING, on_press) + + uart.println("VOLATILE") + + while True: + if flag == 1: + flag = 0 + uart.write(presses) + uart.write('\n') diff --git a/tests/integration/fixtures/zca-array/pyproject.toml b/tests/integration/fixtures/zca-array/pyproject.toml new file mode 100644 index 00000000..e9ff9411 --- /dev/null +++ b/tests/integration/fixtures/zca-array/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "zca-array" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "pymcu-stdlib>=0.1.2a5", + "pymcu>=0.1.0a27" +] + +[tool.pymcu] +target = "atmega328p" +frequency = 16000000 +sources = "src" +entry = "main.py" diff --git a/tests/integration/fixtures/zca-array/src/main.py b/tests/integration/fixtures/zca-array/src/main.py new file mode 100644 index 00000000..506948db --- /dev/null +++ b/tests/integration/fixtures/zca-array/src/main.py @@ -0,0 +1,40 @@ +# RFC 0001 Model B (Class[N]) -- an array of boxed ZCA instances. The "multiples DHT" +# case: N sensors laid out contiguously in SRAM, each its own slot, all driven by ONE shared +# method through a runtime index. sensors[i] is the slot at base + i*stride; sensors[i].read() +# passes that element address as the self pointer. +# +# sensors[0] = Sensor(3, 4) # slot 0: pin=3, gain=4 -> read() = 12 +# sensors[1] = Sensor(5, 7) # slot 1: pin=5, gain=7 -> read() = 35 +# sensors[2] = Sensor(2, 9) # slot 2: pin=2, gain=9 -> read() = 18 +# +# A runtime-indexed loop calls the single shared Sensor_read for each element. +# UART output: "AR\n" banner, then 12, 35, 18. +from pymcu.types import uint8 +from pymcu.hal.uart import UART + + +class Sensor: + def __init__(self, pin: uint8, gain: uint8): + self.pin = pin + self.gain = gain + + def read(self) -> uint8: + return self.pin * self.gain + + +def main(): + uart = UART(9600) + uart.println("AR") + + sensors: Sensor[3] + sensors[0] = Sensor(3, 4) + sensors[1] = Sensor(5, 7) + sensors[2] = Sensor(2, 9) + + i: uint8 = 0 + while i < 3: + uart.write(sensors[i].read()) # runtime index -> shared Sensor_read + i = i + 1 + + while True: + pass diff --git a/tests/integration/fixtures/zca-factory-b/pyproject.toml b/tests/integration/fixtures/zca-factory-b/pyproject.toml new file mode 100644 index 00000000..3dd97213 --- /dev/null +++ b/tests/integration/fixtures/zca-factory-b/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "zca-factory-b" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "pymcu-stdlib>=0.1.2a5", + "pymcu>=0.1.0a27" +] + +[tool.pymcu] +target = "atmega328p" +frequency = 16000000 +sources = "src" +entry = "main.py" diff --git a/tests/integration/fixtures/zca-factory-b/src/main.py b/tests/integration/fixtures/zca-factory-b/src/main.py new file mode 100644 index 00000000..d951588b --- /dev/null +++ b/tests/integration/fixtures/zca-factory-b/src/main.py @@ -0,0 +1,41 @@ +# RFC 0001 Model B (register-packed handle) -- the factory bug, fixed without forcing +# @inline. A single-field ZCA has no runtime struct, so a non-@inline factory returns the +# instance's packed field as a scalar "handle" in the return register. The use site tracks +# `s = make_sensor(...)` as a handle instance, and s.read() (an @outline shared method) +# receives that scalar as its self field. +# +# make_sensor(20) returns the packed pin = 20 + 1 = 21 +# s.read() = self.pin * 2 = 21 * 2 = 42 = '*' +# make_sensor(40) returns 41; t.read() = 82 = 'R' +# +# UART output: "FB\n" banner, then '*' (42), then 'R' (82) -> contains "*R". +from pymcu.types import uint8 +from pymcu.hal.uart import UART + + +class Sensor: + def __init__(self, pin: uint8): + self.pin = pin + + def read(self) -> uint8: + return self.pin * 2 + + +# Non-@inline factory: returns a ZCA across a real call boundary. Before Model B this +# failed at link with `undefined reference to _read`. +def make_sensor(base: uint8) -> Sensor: + return Sensor(base + 1) + + +def main(): + uart = UART(9600) + uart.println("FB") + + s = make_sensor(20) # handle = 21 + t = make_sensor(40) # handle = 41 + + uart.write(s.read()) # 42 = '*' + uart.write(t.read()) # 82 = 'R' + + while True: + pass diff --git a/tests/integration/fixtures/zca-factory-slot/pyproject.toml b/tests/integration/fixtures/zca-factory-slot/pyproject.toml new file mode 100644 index 00000000..0f9fbf60 --- /dev/null +++ b/tests/integration/fixtures/zca-factory-slot/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "zca-factory-slot" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "pymcu-stdlib>=0.1.2a5", + "pymcu>=0.1.0a27" +] + +[tool.pymcu] +target = "atmega328p" +frequency = 16000000 +sources = "src" +entry = "main.py" diff --git a/tests/integration/fixtures/zca-factory-slot/src/main.py b/tests/integration/fixtures/zca-factory-slot/src/main.py new file mode 100644 index 00000000..73369d95 --- /dev/null +++ b/tests/integration/fixtures/zca-factory-slot/src/main.py @@ -0,0 +1,40 @@ +# RFC 0001 Model B (sret) -- a non-@inline factory returning a MULTI-field ZCA. A single field +# fits in the return register (register handle); two fields don't, so the factory uses sret: +# the CALLER allocates the instance's SRAM slot and passes its address as a hidden __self +# pointer; the factory stores each field through it and returns the pointer. The instance's +# (default-outlined) method then reads fields from that slot via self-ptr. +# +# s = make(3, 4) -> slot {pin:3, gain:4}; s.read() = 3*4 = 12 +# t = make(5, 7) -> distinct slot {pin:5, gain:7}; t.read() = 5*7 = 35 +# +# Two factory calls => two distinct slots (no aliasing, because the caller owns each slot). +# UART output: "FS\n" banner, then 12, then 35. +from pymcu.types import uint8 +from pymcu.hal.uart import UART + + +class Sensor: + def __init__(self, pin: uint8, gain: uint8): + self.pin = pin + self.gain = gain + + def read(self) -> uint8: + return self.pin * self.gain + + +def make(pin: uint8, gain: uint8) -> Sensor: + return Sensor(pin, gain) + + +def main(): + uart = UART(9600) + uart.println("FS") + + s = make(3, 4) + t = make(5, 7) + + uart.write(s.read()) # 12 + uart.write(t.read()) # 35 + + while True: + pass diff --git a/tests/integration/fixtures/zca-factory/pyproject.toml b/tests/integration/fixtures/zca-factory/pyproject.toml new file mode 100644 index 00000000..2985472c --- /dev/null +++ b/tests/integration/fixtures/zca-factory/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "zca-factory" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = ["pymcu-stdlib>=0.1.0", "pymcu-micropython>=0.1.0a1"] + +[tool.pymcu] +board = "arduino_uno" +frequency = 16000000 +sources = "src" +entry = "main.py" +stdlib = ["micropython"] diff --git a/tests/integration/fixtures/zca-factory/src/main.py b/tests/integration/fixtures/zca-factory/src/main.py new file mode 100644 index 00000000..c14378d3 --- /dev/null +++ b/tests/integration/fixtures/zca-factory/src/main.py @@ -0,0 +1,20 @@ +# An @inline factory may return a ZCA instance; the result's methods must still +# inline. `a = make_adc()` used to mangle a.read() to an undefined symbol. +from machine import Pin, ADC, PWM, UART +from pymcu.types import inline, uint8 + + +@inline +def make_adc(ch: uint8) -> ADC: + return ADC(Pin(ch)) + + +def main(): + uart = UART(0, 9600) + pot = make_adc(14) # A0 via factory + led = PWM(Pin("PD6")) + led.init() + while True: + d: uint8 = uint8(pot.read() >> 2) + led.duty(d) + uart.write(d) # echo duty (sync marker for tests) diff --git a/tests/integration/fixtures/zca-outline-dht/pyproject.toml b/tests/integration/fixtures/zca-outline-dht/pyproject.toml new file mode 100644 index 00000000..ac9a5072 --- /dev/null +++ b/tests/integration/fixtures/zca-outline-dht/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "zca-outline-dht" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "pymcu-stdlib>=0.1.2a5", + "pymcu>=0.1.0a27" +] + +[tool.pymcu] +target = "atmega328p" +frequency = 16000000 +sources = "src" +entry = "main.py" diff --git a/tests/integration/fixtures/zca-outline-dht/src/main.py b/tests/integration/fixtures/zca-outline-dht/src/main.py new file mode 100644 index 00000000..4db3b2c0 --- /dev/null +++ b/tests/integration/fixtures/zca-outline-dht/src/main.py @@ -0,0 +1,111 @@ +# RFC 0001 Model A on a DHT-style driver -- the BLOAT case the user cares about. +# +# This driver is written the NATURAL way: the full bit-bang read protocol lives +# directly in DHT.read(self), using the instance's runtime field self.pin. There is +# NO manual "thin @inline dispatch + shared runtime worker" split (the trick the +# shipped dht11.py uses to avoid bloat by hand). +# +# With @outline, the compiler shares one DHT_read body across all three sensors -- +# they each call it with their own pin. Flip @outline -> @inline and the entire +# protocol is duplicated three times in flash. That delta is the whole point. +# +# No sensor is attached in simulation, so the ACK wait times out and read() returns +# 0xFFFF for every pin; the test asserts the shared body ran for each instance and +# that exactly one DHT_read label exists. +from pymcu.types import uint8, uint16, inline +from pymcu.chips.atmega328p import DDRD, PORTD, PIND +from pymcu.time import delay_ms, delay_us +from pymcu.hal.uart import UART + + +@inline +def _wait(mask: uint8, level: uint8) -> uint8: + timeout: uint8 = 255 + while timeout > 0: + current: uint8 = PIND.value & mask + if level == 0: + if current == 0: + return timeout + else: + if current: + return timeout + timeout = timeout - 1 + return 0 + + +@inline +def _byte(mask: uint8) -> uint8: + result: uint8 = 0 + bit: uint8 = 0 + while bit < 8: + if _wait(mask, 0) == 0: + return 0 + if _wait(mask, 1) == 0: + return 0 + count: uint8 = 0 + while PIND.value & mask: + count = count + 1 + if count == 255: + break + result = result << 1 + if count > 35: + result = result | 1 + bit = bit + 1 + return result + + +class DHT: + def __init__(self, pin: uint8): + self.pin = pin + + # Written naturally: the whole protocol is here, keyed on self.pin. @outline makes + # the compiler emit ONE shared body taking self_pin as a parameter. + def read(self) -> uint16: + mask: uint8 = 1 << self.pin + + DDRD.value = DDRD.value | mask + PORTD.value = PORTD.value & ~mask + delay_ms(18) + DDRD.value = DDRD.value & ~mask + PORTD.value = PORTD.value | mask + delay_us(40) + + if _wait(mask, 0) == 0: + return 0xFFFF + if _wait(mask, 1) == 0: + return 0xFFFF + + hum_int: uint8 = _byte(mask) + hum_dec: uint8 = _byte(mask) + temp_int: uint8 = _byte(mask) + temp_dec: uint8 = _byte(mask) + chksum: uint8 = _byte(mask) + + expected: uint8 = (hum_int + hum_dec + temp_int + temp_dec) & 0xFF + if chksum != expected: + return 0xFFFF + + result: uint16 = hum_int + result = (result << 8) | temp_int + return result + + +def main(): + uart = UART(9600) + uart.println("DHT") + + a = DHT(2) + b = DHT(3) + c = DHT(4) + + r1: uint16 = a.read() + r2: uint16 = b.read() + r3: uint16 = c.read() + + # All error out (no sensor): low byte of each is 0xFF -> write 0xFF thrice. + uart.write(uint8(r1 & 0xFF)) + uart.write(uint8(r2 & 0xFF)) + uart.write(uint8(r3 & 0xFF)) + + while True: + pass diff --git a/tests/integration/fixtures/zca-outline-selfcall/pyproject.toml b/tests/integration/fixtures/zca-outline-selfcall/pyproject.toml new file mode 100644 index 00000000..c1b2ab82 --- /dev/null +++ b/tests/integration/fixtures/zca-outline-selfcall/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "zca-outline-selfcall" +version = "0.1.0" +dependencies = ["pymcu-stdlib"] + +[tool.pymcu] +target = "atmega328p" +frequency = 16000000 +sources = "src" +entry = "main.py" diff --git a/tests/integration/fixtures/zca-outline-selfcall/src/main.py b/tests/integration/fixtures/zca-outline-selfcall/src/main.py new file mode 100644 index 00000000..b1d72056 --- /dev/null +++ b/tests/integration/fixtures/zca-outline-selfcall/src/main.py @@ -0,0 +1,41 @@ +# RFC 0001 phase 2 -- an outlined ZCA method that CALLS A SIBLING method on self. +# +# Dev.compute() calls self.helper() twice. Previously compute was not outline-safe +# (it touches self via a method call, not just a field), so it was force-inlined at +# every call site -- bloat that grows with both call count and instance count. Now +# compute is outlined ONCE and forwards its own self (here Model A: the self_base +# field param) to the shared Dev_helper body. +# +# a = Dev(3): compute(1) = helper(1) + helper(2) = (3+1) + (3+2) = 4 + 5 = 9 +# b = Dev(5): compute(2) = helper(2) + helper(3) = (5+2) + (5+3) = 7 + 8 = 15 +# +# UART output (9600 baud): "SC\n" banner, then byte 9, then byte 15. +# Correctness proof: 9 and 15 require compute to forward each instance's runtime +# base (3, 5) into the shared helper -- not a baked constant. +from pymcu.types import uint8 +from pymcu.hal.uart import UART + + +class Dev: + def __init__(self, base: uint8): + self.base = base + + def helper(self, n: uint8) -> uint8: + return self.base + n + + def compute(self, n: uint8) -> uint8: + return self.helper(n) + self.helper(n + 1) + + +def main(): + uart = UART(9600) + uart.println("SC") + + a = Dev(3) + b = Dev(5) + + uart.write(a.compute(1)) # 9 + uart.write(b.compute(2)) # 15 + + while True: + pass diff --git a/tests/integration/fixtures/zca-outline/pyproject.toml b/tests/integration/fixtures/zca-outline/pyproject.toml new file mode 100644 index 00000000..e3b0c24a --- /dev/null +++ b/tests/integration/fixtures/zca-outline/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "zca-outline" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "pymcu-stdlib>=0.1.2a5", + "pymcu>=0.1.0a27" +] + +[tool.pymcu] +target = "atmega328p" +frequency = 16000000 +sources = "src" +entry = "main.py" diff --git a/tests/integration/fixtures/zca-outline/src/main.py b/tests/integration/fixtures/zca-outline/src/main.py new file mode 100644 index 00000000..f6078097 --- /dev/null +++ b/tests/integration/fixtures/zca-outline/src/main.py @@ -0,0 +1,35 @@ +# RFC 0001 Model A (@outline) -- a ZCA method compiled ONCE as a shared subroutine +# that receives the instance's runtime field (self.base) as a parameter, instead of +# being force-inlined per call site. Two Counter instances call the SAME Counter_stepped +# body with different runtime field values: +# +# a = Counter(65 = 'A'); a.stepped(1) = 65 + 1 = 66 = 'B' +# b = Counter(97 = 'a'); b.stepped(2) = 97 + 2 = 99 = 'c' +# +# UART output (9600 baud): "OL\n" banner, then 'B', then 'c' -> "OLBc". +# Outlining proof: exactly one `Counter_stepped:` label exists in the firmware, +# yet two distinct instances drive it -- no per-instance code duplication. +from pymcu.types import uint8 +from pymcu.hal.uart import UART + + +class Counter: + def __init__(self, base: uint8): + self.base = base + + def stepped(self, k: uint8) -> uint8: + return self.base + k + + +def main(): + uart = UART(9600) + uart.println("OL") + + a = Counter(65) + b = Counter(97) + + uart.write(a.stepped(1)) # 'B' + uart.write(b.stepped(2)) # 'c' + + while True: + pass diff --git a/tests/integration/fixtures/zca-slot/pyproject.toml b/tests/integration/fixtures/zca-slot/pyproject.toml new file mode 100644 index 00000000..6f009d6e --- /dev/null +++ b/tests/integration/fixtures/zca-slot/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "zca-slot" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "pymcu-stdlib>=0.1.2a5", + "pymcu>=0.1.0a27" +] + +[tool.pymcu] +target = "atmega328p" +frequency = 16000000 +sources = "src" +entry = "main.py" diff --git a/tests/integration/fixtures/zca-slot/src/main.py b/tests/integration/fixtures/zca-slot/src/main.py new file mode 100644 index 00000000..f1cb1c6a --- /dev/null +++ b/tests/integration/fixtures/zca-slot/src/main.py @@ -0,0 +1,35 @@ +# RFC 0001 Model B (SRAM slot) -- a multi-field ZCA. A single field fits in a register +# (Model A / register handle), but >= 2 fields don't, so the instance is "boxed": its +# fields live in a fixed SRAM slot and its @outline method takes a `self` pointer, reading +# each field via BytearrayLoad at a byte offset. Two instances => two 2-byte slots, ONE +# shared Sensor_read body that walks the pointer it is handed. +# +# a = Sensor(3, 4) -> slot {pin:3, gain:4}; a.read() = 3*4 = 12 +# b = Sensor(5, 7) -> slot {pin:5, gain:7}; b.read() = 5*7 = 35 +# +# UART output: "SL\n" banner, then 12, then 35. +from pymcu.types import uint8 +from pymcu.hal.uart import UART + + +class Sensor: + def __init__(self, pin: uint8, gain: uint8): + self.pin = pin + self.gain = gain + + def read(self) -> uint8: + return self.pin * self.gain + + +def main(): + uart = UART(9600) + uart.println("SL") + + a = Sensor(3, 4) + b = Sensor(5, 7) + + uart.write(a.read()) # 12 + uart.write(b.read()) # 35 + + while True: + pass diff --git a/tests/unit/Backend/AVRCodeGenTests.cs b/tests/unit/Backend/AVRCodeGenTests.cs index b9d6b25c..f63409e8 100644 --- a/tests/unit/Backend/AVRCodeGenTests.cs +++ b/tests/unit/Backend/AVRCodeGenTests.cs @@ -778,15 +778,21 @@ public void Float_Gt_EmitsCallToFpGt() public void Float_Return_LoadsIntoR22R25() { // Returning a float variable must load into R22:R25 (soft-float return convention). - // Copy gives the variable a stack slot; Return must then emit 4 LDD instructions. - var fc = new FloatConstant(1.0); + // The intervening Copy(fc2 -> b) clobbers all four bytes of R22:R25 (1.1f and 7.0f + // share no byte values), so returning `a` cannot reuse the registers from its own + // Copy and must genuinely reload its slot via 4 LDDs. (Without the clobber, + // store-to-load forwarding correctly elides the reload, since a's value is still in + // R22:R25 — see ForwardStores in AvrPeephole.) + var fc = new FloatConstant(1.1); + var fc2 = new FloatConstant(7.0); var a = new Variable("a", DataType.FLOAT); + var b = new Variable("b", DataType.FLOAT); var prog = new ProgramIR(); prog.Functions.Add(new Function { Name = "main", ReturnType = DataType.FLOAT, - Body = [new Copy(fc, a), new Return(a)] + Body = [new Copy(fc, a), new Copy(fc2, b), new Return(a)] }); var asm = Compile(prog); diff --git a/tests/unit/Backend/AvrLinearScanTests.cs b/tests/unit/Backend/AvrLinearScanTests.cs index 5d46e191..9ceefc63 100644 --- a/tests/unit/Backend/AvrLinearScanTests.cs +++ b/tests/unit/Backend/AvrLinearScanTests.cs @@ -6,7 +6,8 @@ namespace PyMCU.UnitTests; /// /// Unit tests for AvrLinearScan.Allocate() — the greedy linear-scan register -/// allocator that maps short-lived UINT8 temporaries to R16/R17. +/// allocator that maps short-lived temporaries to R16/R17: an 8-bit temp takes +/// one slot, a 16-bit temp takes the R16:R17 pair (low in R16). /// public class AvrLinearScanTests { @@ -117,18 +118,36 @@ public void TemporarySpanningCall_NotAllocated() "t1 spans a call and must not be allocated to a scratch register"); } - // ─── UINT16 temporary is not eligible ──────────────────────────────────── + // ─── UINT16 temporary takes the R16:R17 pair ───────────────────────────── [Fact] - public void Uint16Temporary_NotAllocated() + public void Uint16Temporary_AssignedToR16Pair() { + // A non-call-spanning 16-bit temporary occupies the whole R16:R17 pair, + // homed at R16 (low byte); the codegen drives R17 via GetHighReg. var t1 = new Temporary("t1", DataType.UINT16); var result = Allocate( new Copy(new Constant(500), t1), new Return(t1)); + Assert.True(result.TryGetValue("t1", out var reg), + "a non-call-spanning UINT16 temporary is allocated to the R16:R17 pair"); + Assert.Equal("R16", reg); + } + + // ─── UINT16 temporary spanning a call is still spilled ──────────────────── + + [Fact] + public void Uint16Temporary_SpanningCall_NotAllocated() + { + var t1 = new Temporary("t1", DataType.UINT16); + var result = Allocate( + new Copy(new Constant(500), t1), // 0 — t1 def + new Call("some_func", new List(), new NoneVal()), // 1 — call + new Return(t1)); // 2 — t1 last use + Assert.False(result.ContainsKey("t1"), - "UINT16 temporaries must not be placed in R16/R17 (single-byte scratch regs)"); + "a UINT16 temporary spanning a call must not be placed in caller-saved R16:R17"); } // ─── Empty function ───────────────────────────────────────────────────────