diff --git a/src/RP2040Sharp/Core/Cpu/CortexM0Plus.cs b/src/RP2040Sharp/Core/Cpu/CortexM0Plus.cs index 1d2ee3c..ecad186 100644 --- a/src/RP2040Sharp/Core/Cpu/CortexM0Plus.cs +++ b/src/RP2040Sharp/Core/Cpu/CortexM0Plus.cs @@ -49,6 +49,23 @@ public sealed unsafe class CortexM0Plus /// public Action? OnLockup; + /// + /// Called when the CPU takes an exception (ISR / fault / SysTick / PendSV / SVCall). + /// The parameter is the exception number being entered (same value written to IPSR). + /// Fires once per exception entry — never on the per-instruction hot path — and is + /// intended for profiling/tracing the boundary between thread code and handler code. + /// + public Action? OnExceptionEntry; + + /// + /// Called when the CPU returns from an exception (EXC_RETURN consumed). The parameter + /// is the EXC_RETURN value that drove the return. By the time this fires the registers, + /// SP and PC have already been restored to the resumed context, so a profiler can read + /// the return PC / stack to identify the task being resumed. + /// Fires once per exception return — never on the per-instruction hot path. + /// + public Action? OnExceptionReturn; + /// /// Native hooks: when the PC equals a registered address (Thumb bit stripped), the /// corresponding delegate is called instead of fetching/executing an instruction. @@ -228,6 +245,112 @@ public void Run(int instructions) _fetchMask = fetchMask; } + /// + /// Profiling-only sibling of . Identical execution semantics, but + /// invokes once per instruction (before dispatch). This is a + /// deliberately separate method so the hot path is byte-for-byte + /// unchanged and pays nothing for profiling — mirroring AVR8Sharp's + /// Execute()/ExecuteProfiling() split. Do not use for throughput-sensitive simulation. + /// + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public void RunProfiled(int instructions, IProfilingObserver observer) + { + var decoder = _decoder; + + var fetchPtr = _fetchPtr; + var fetchMask = _fetchMask; + var regionId = _currentRegionId; + + while (instructions-- > 0) + { + if (IsLockedUp) return; + + if (Registers.InterruptsUpdated) + { + Registers.InterruptsUpdated = false; + if (CheckForInterrupts()) + { + UpdateFetchCache(Registers.PC); + fetchPtr = _fetchPtr; + fetchMask = _fetchMask; + regionId = _currentRegionId; + } + } + + if (Registers.Waiting) + { + if (Registers.EventRegistered) + { + Registers.Waiting = false; + Registers.EventRegistered = false; + } + else + { + Cycles += (uint)(instructions + 1); + return; + } + } + + var pc = Registers.PC; + + if ((pc >> 28) != regionId) + { + UpdateFetchCache(pc); + + fetchPtr = _fetchPtr; + fetchMask = _fetchMask; + regionId = _currentRegionId; + + if (fetchPtr == null) + { + ExceptionEntry(EXC_HARDFAULT); + if (IsLockedUp) return; + UpdateFetchCache(Registers.PC); + fetchPtr = _fetchPtr; + fetchMask = _fetchMask; + regionId = _currentRegionId; + continue; + } + } + + if (_nativeHooks != null && pc <= _nativeHookMax && _nativeHooks.TryGetValue(pc, out var nativeHook)) + { + observer.OnInstruction(pc, Unsafe.ReadUnaligned(fetchPtr + (pc & fetchMask)), Cycles); + + var pcBeforeHook = Registers.PC; + nativeHook(this); + if (Registers.PC == pcBeforeHook) + { + var hookLr = Registers.LR; + if (hookLr >= 0xFFFFFFF0) + ExceptionReturn(hookLr); + else + Registers.PC = hookLr & ~1u; + } + UpdateFetchCache(Registers.PC); + fetchPtr = _fetchPtr; + fetchMask = _fetchMask; + regionId = _currentRegionId; + Cycles++; + continue; + } + + var opcode = Unsafe.ReadUnaligned(fetchPtr + (pc & fetchMask)); + + observer.OnInstruction(pc, opcode, Cycles); + + Registers.PC = pc + 2; + + Cycles++; + + decoder.Dispatch(opcode, this); + } + + _currentRegionId = regionId; + _fetchPtr = fetchPtr; + _fetchMask = fetchMask; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Step() { @@ -332,6 +455,8 @@ public void ExceptionEntry(uint exceptionNumber) Registers.PC = targetPc & 0xFFFFFFFE; Cycles += 12; // Exception Entry cost (aprox 12-15 cycles) + + OnExceptionEntry?.Invoke(exceptionNumber); } // ================================================================ @@ -490,5 +615,7 @@ public void ExceptionReturn(uint excReturn) // that already happened. rp2040js cortex-m0-core.ts:339-341 sets both flags. Registers.InterruptsUpdated = true; Registers.EventRegistered = true; + + OnExceptionReturn?.Invoke(excReturn); } } diff --git a/src/RP2040Sharp/Core/Cpu/IProfilingObserver.cs b/src/RP2040Sharp/Core/Cpu/IProfilingObserver.cs new file mode 100644 index 0000000..6c1e256 --- /dev/null +++ b/src/RP2040Sharp/Core/Cpu/IProfilingObserver.cs @@ -0,0 +1,22 @@ +namespace RP2040.Core.Cpu; + +/// +/// Observation hook invoked once per executed instruction on the profiling-only +/// execution path (). +/// +/// This is deliberately a separate path from the hot +/// loop: the normal simulation never references an observer, so per-instruction +/// profiling overhead cannot leak into the fast path. (Same separation AVR8Sharp +/// uses between its struct LUT decoders and its ProfilingDecoder.) +/// +/// The callback fires before the instruction is dispatched, so the observer +/// sees the architectural state (PC, registers, memory) as it was on entry to the +/// instruction at . +/// +public interface IProfilingObserver +{ + /// Program counter of the instruction about to execute (Thumb bit stripped). + /// The 16-bit halfword fetched at . + /// Cycle counter immediately before this instruction executes. + void OnInstruction(uint pc, ushort opcode, long cycles); +} diff --git a/src/RP2040Sharp/Core/Memory/Ram.cs b/src/RP2040Sharp/Core/Memory/Ram.cs index 66635f8..155b174 100644 --- a/src/RP2040Sharp/Core/Memory/Ram.cs +++ b/src/RP2040Sharp/Core/Memory/Ram.cs @@ -1,4 +1,4 @@ -using System.Buffers.Binary; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -6,17 +6,19 @@ namespace RP2040.Core.Memory; public sealed unsafe class RandomAccessMemory : IMemoryMappedDevice, IDisposable { - readonly byte[] _memory; - GCHandle _pinnedHandle; - + // Backing store is unmanaged native memory (not a pinned managed array). This keeps the + // multi-megabyte RAM/Flash/BootROM blocks out of the GC heap entirely — a pinned array of + // this size would fragment the heap and resist compaction for its whole lifetime. Mirrors + // the unmanaged-buffer pattern used by InstructionDecoder. public readonly byte* BasePtr; public uint Size { get; } + private bool _disposed; + public RandomAccessMemory(int size) { - _memory = new byte[size]; - _pinnedHandle = GCHandle.Alloc(_memory, GCHandleType.Pinned); - BasePtr = (byte*)_pinnedHandle.AddrOfPinnedObject(); + // AllocZeroed preserves the zero-initialised semantics of `new byte[size]`. + BasePtr = (byte*)NativeMemory.AllocZeroed((nuint)size); Size = (uint)size; } @@ -49,11 +51,23 @@ public void WriteHalfWord(uint address, ushort value) => public void WriteWord(uint address, uint value) => Unsafe.WriteUnaligned(BasePtr + address, value); + [ExcludeFromCodeCoverage] + ~RandomAccessMemory() + { + Free(); + } + public void Dispose() { - if (_pinnedHandle.IsAllocated) - { - _pinnedHandle.Free(); - } + Free(); + GC.SuppressFinalize(this); + } + + private void Free() + { + if (_disposed) + return; + NativeMemory.Free(BasePtr); + _disposed = true; } } diff --git a/src/RP2040Sharp/Peripherals/Pio/PioPeripheral.cs b/src/RP2040Sharp/Peripherals/Pio/PioPeripheral.cs index 232f0cd..1214602 100644 --- a/src/RP2040Sharp/Peripherals/Pio/PioPeripheral.cs +++ b/src/RP2040Sharp/Peripherals/Pio/PioPeripheral.cs @@ -136,13 +136,18 @@ public uint ReadWord(uint address) { var smIdx = (int)((off - REG_RXF_BASE) / 4); var sm = _sm[smIdx]; - if (!sm.RxFifo.TryDequeue(out var v)) return 0; + if (!sm.RxFifo.TryDequeue(out var v)) + { + // RX FIFO underflow: reading an empty RX FIFO. TRM §3.7 FDEBUG.RXUNDER = bits [11:8]. + _fdebug |= 1u << (8 + smIdx); + return 0; + } // Clear RXSTALL for this SM now that RX FIFO has space _fdebug &= ~(1u << smIdx); // Wake a SM that was stalled waiting for space in the RX FIFO (PUSH block / autopush). // rp2040js: readFIFO() → checkWait(). if (sm.Stalled) - CheckSmWait(sm, smIdx); + CheckSmWait(sm, txEvent: false); return v; } @@ -200,11 +205,11 @@ public void WriteWord(uint address, uint value) // Wake a SM that was stalled waiting for data in the TX FIFO (PULL block / autopull). // rp2040js: writeFIFO() → checkWait(). if (sm.Stalled) - CheckSmWait(sm, smIdx); + CheckSmWait(sm, txEvent: true); } else { - _fdebug |= 1u << (8 + smIdx); // TXOVER bits [11:8] + _fdebug |= 1u << (16 + smIdx); // TXOVER bits [19:16] (TRM §3.7) } return; } @@ -257,28 +262,59 @@ public void InjectRxData(int smIndex, uint value) // ── Private: stall wake-up ─────────────────────────────────────── /// - /// Re-evaluate the stall condition for a SM that was blocked on a FIFO or IRQ wait. - /// Mirrors rp2040js StateMachine.checkWait(): the SM may immediately unstall and - /// advance its PC if the blocking condition has been resolved. - /// Called after TXF writes (may unblock PULL-stalled SM) and RXF reads (may unblock PUSH-stalled SM). + /// Immediately re-evaluate the stall of a SM after a FIFO event, so it can resume within the + /// same CPU step instead of waiting for the next . Mirrors rp2040js + /// StateMachine.checkWait(). + /// = true after a TXF write (may unblock a PULL or an + /// autopull-stalled OUT); false after an RXF read (may unblock a PUSH or a deferred autopush). + /// The two FIFO events are handled separately: freeing RX space must not disturb a TX-waiter, + /// and vice-versa. /// - private void CheckSmWait(PioStateMachine sm, int smIdx) + private void CheckSmWait(PioStateMachine sm, bool txEvent) { - // Try to complete a blocked PULL (SM was stalled because TX FIFO was empty). - if (sm.TxFifo.Count > 0) + if (txEvent) { - sm.OSR = sm.TxFifo.Dequeue(); - sm.OsrCount = 32; - sm.Stalled = false; - AdvanceSmPc(sm); + if (sm.TxFifo.Count == 0) return; + var instr = _instrMem[sm.PC & 0x1F]; + if (((instr >> 13) & 7) == OP_PUSH_PULL) + { + // Blocking PULL: its sole effect is to load the OSR — do it and step past it. + sm.OSR = sm.TxFifo.Dequeue(); + sm.OsrCount = 32; + sm.Stalled = false; + AdvanceSmPc(sm); + } + else + { + // Autopull stall on an OUT (or MOV): the instruction has not yet produced its + // result. Just clear the stall so it re-executes and refills the OSR itself — + // advancing the PC here would skip the OUT entirely. + sm.Stalled = false; + } } - // Try to complete a blocked PUSH (SM was stalled because RX FIFO was full). - else if (sm.RxFifo.Count < sm.RxDepth && sm.IsrCount >= (uint)sm.AutopushThreshold) + else { - sm.RxFifo.Enqueue(sm.ISR); - sm.ISR = 0; sm.IsrCount = 0; - sm.Stalled = false; - AdvanceSmPc(sm); + if (sm.RxFifo.Count >= sm.RxDepth) return; + if (sm.AutopushPending) + { + // Deferred autopush: the ISR already holds the captured data; flush it and + // step past the IN that stalled. + sm.RxFifo.Enqueue(sm.ISR); + sm.ISR = 0; sm.IsrCount = 0; + sm.AutopushPending = false; + sm.Stalled = false; + AdvanceSmPc(sm); + return; + } + var instr = _instrMem[sm.PC & 0x1F]; + if (((instr >> 13) & 7) == OP_PUSH_PULL && (instr & 0x80) == 0) + { + // Blocking PUSH: flush the ISR and step past it. + sm.RxFifo.Enqueue(sm.ISR); + sm.ISR = 0; sm.IsrCount = 0; + sm.Stalled = false; + AdvanceSmPc(sm); + } } } @@ -342,6 +378,7 @@ private void WriteCtrl(uint value) // which always satisfies any PULL_THRESH ≤ 32. _sm[i].OSR = 0; _sm[i].OsrCount = 0; _sm[i].Stalled = false; + _sm[i].AutopushPending = false; // Clear EXEC_STALLED status in EXECCTRL (bit 31) _sm[i].ExecCtrl &= 0x7FFFFFFFu; } @@ -471,6 +508,23 @@ private void ExecuteStep(PioStateMachine sm, int smIdx) return; } + // Complete a deferred autopush before fetching the next instruction. The IN already + // shifted its data; we must NOT re-execute it (that would shift fresh data). Retry the + // push, and advance past the IN once it succeeds. + if (sm.AutopushPending) + { + if (sm.RxFifo.Count < sm.RxDepth) + { + sm.RxFifo.Enqueue(sm.ISR); + sm.ISR = 0; sm.IsrCount = 0; + sm.AutopushPending = false; + sm.Stalled = false; + sm.PC++; + if (sm.PC > sm.WrapTop) sm.PC = sm.WrapBottom; + } + return; + } + if (sm.PC >= INSTR_COUNT) sm.PC = (uint)(sm.WrapBottom & 0x1F); var instr = _instrMem[sm.PC]; @@ -663,9 +717,24 @@ private void ExecIn(PioStateMachine sm, ushort instr) sm.ISR = bitCount == 32 ? data : (sm.ISR << bitCount) | (data & ((1u << bitCount) - 1)); } sm.IsrCount += (uint)bitCount; + if (sm.IsrCount > 32) sm.IsrCount = 32; // shift counter saturates at 32 (TRM §3.5.4) if (sm.AutopushEnabled && sm.IsrCount >= (uint)sm.AutopushThreshold) - DoPush(sm, false); + { + if (sm.RxFifo.Count < sm.RxDepth) + { + sm.RxFifo.Enqueue(sm.ISR); + sm.ISR = 0; sm.IsrCount = 0; + } + else + { + // RX FIFO full: stall WITHOUT discarding. The data already shifted into the ISR + // is retained; the deferred push completes once the RX FIFO drains (TRM §3.5.4.2). + sm.Stalled = true; + sm.AutopushPending = true; + _fdebug |= 1u << sm.SmIndex; // RXSTALL [3:0] + } + } } // OUT: bits [7:5]=destination, [4:0]=bit count (0=32) diff --git a/src/RP2040Sharp/Peripherals/Pio/PioStateMachine.cs b/src/RP2040Sharp/Peripherals/Pio/PioStateMachine.cs index a9ed843..e7ed4d2 100644 --- a/src/RP2040Sharp/Peripherals/Pio/PioStateMachine.cs +++ b/src/RP2040Sharp/Peripherals/Pio/PioStateMachine.cs @@ -29,6 +29,12 @@ internal sealed class PioStateMachine // ── Execution state ────────────────────────────────────────────── public bool Enabled; public bool Stalled; // waiting for FIFO or condition + /// + /// An IN already shifted its data into the ISR and reached the autopush threshold, but the + /// RX FIFO was full so the push could not complete. The SM stalls without re-shifting; the + /// deferred push completes once the RX FIFO drains. Mirrors RP2040 datasheet §3.5.4.2. + /// + internal bool AutopushPending; internal bool PcJumped; // JMP or MOV PC set a new PC directly (skip auto-increment) internal long FracAccum; // for sub-cycle fractional clock divisor internal int DelayCounter; // instruction delay cycles remaining @@ -42,7 +48,7 @@ public void Reset() { PC = 0; X = 0; Y = 0; ISR = 0; OSR = 0; IsrCount = 0; OsrCount = 0; - Stalled = false; PcJumped = false; FracAccum = 0; DelayCounter = 0; + Stalled = false; AutopushPending = false; PcJumped = false; FracAccum = 0; DelayCounter = 0; TxFifo.Clear(); RxFifo.Clear(); } diff --git a/tests/RP2040Sharp.IntegrationTests/Firmware/python/circuitpython-9.2.1.uf2 b/tests/RP2040Sharp.IntegrationTests/Firmware/python/circuitpython-9.2.1.uf2 new file mode 100644 index 0000000..5824805 Binary files /dev/null and b/tests/RP2040Sharp.IntegrationTests/Firmware/python/circuitpython-9.2.1.uf2 differ diff --git a/tests/RP2040Sharp.IntegrationTests/Firmware/python/micropython-v1.19.1.uf2 b/tests/RP2040Sharp.IntegrationTests/Firmware/python/micropython-v1.19.1.uf2 new file mode 100644 index 0000000..87d32c4 Binary files /dev/null and b/tests/RP2040Sharp.IntegrationTests/Firmware/python/micropython-v1.19.1.uf2 differ diff --git a/tests/RP2040Sharp.IntegrationTests/Firmware/python/micropython-v1.20.0.uf2 b/tests/RP2040Sharp.IntegrationTests/Firmware/python/micropython-v1.20.0.uf2 new file mode 100644 index 0000000..14880cb Binary files /dev/null and b/tests/RP2040Sharp.IntegrationTests/Firmware/python/micropython-v1.20.0.uf2 differ diff --git a/tests/RP2040Sharp.IntegrationTests/Firmware/python/micropython-v1.21.0.uf2 b/tests/RP2040Sharp.IntegrationTests/Firmware/python/micropython-v1.21.0.uf2 new file mode 100644 index 0000000..bf698b5 Binary files /dev/null and b/tests/RP2040Sharp.IntegrationTests/Firmware/python/micropython-v1.21.0.uf2 differ diff --git a/tests/RP2040Sharp.IntegrationTests/Infrastructure/FirmwareCache.cs b/tests/RP2040Sharp.IntegrationTests/Infrastructure/FirmwareCache.cs index f416fc1..33bed42 100644 --- a/tests/RP2040Sharp.IntegrationTests/Infrastructure/FirmwareCache.cs +++ b/tests/RP2040Sharp.IntegrationTests/Infrastructure/FirmwareCache.cs @@ -22,6 +22,13 @@ public static class FirmwareCache if (File.Exists(path) && new FileInfo(path).Length > 0) return path; + // Prefer firmware embedded in the test assembly — offline and free of network flakiness. + if (TryLoadEmbedded($"micropython-{version}") is { } embedded) + { + await File.WriteAllBytesAsync(path, embedded); + return path; + } + try { using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }; @@ -44,6 +51,29 @@ public static class FirmwareCache } } + /// + /// Loads a firmware UF2 embedded in this test assembly (under Firmware/python/), matched by a + /// substring of its filename (e.g. "micropython-v1.21.0" or "circuitpython-9.2.1"). Matching by + /// substring avoids depending on MSBuild's exact manifest-resource name mangling. + /// Returns null when no embedded firmware matches (caller falls back to downloading). + /// + private static byte[]? TryLoadEmbedded(string fileNameContains) + { + var asm = typeof(FirmwareCache).Assembly; + var name = asm.GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith(".uf2", StringComparison.OrdinalIgnoreCase) + && n.Contains(fileNameContains, StringComparison.OrdinalIgnoreCase)); + if (name is null) + return null; + + using var stream = asm.GetManifestResourceStream(name); + if (stream is null) + return null; + using var ms = new MemoryStream(); + stream.CopyTo(ms); + return ms.ToArray(); + } + private static async Task ResolveMicroPythonUrlAsync(HttpClient http, string version) { // Firmware listed at https://micropython.org/download/RPI_PICO/ @@ -80,6 +110,14 @@ public static class FirmwareCache if (File.Exists(path) && new FileInfo(path).Length > 0) return path; + // Prefer firmware embedded in the test assembly — offline and free of network flakiness. + var embeddedTag = version.StartsWith('v') ? version[1..] : version; + if (TryLoadEmbedded($"circuitpython-{embeddedTag}") is { } embedded) + { + await File.WriteAllBytesAsync(path, embedded); + return path; + } + try { using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }; diff --git a/tests/RP2040Sharp.IntegrationTests/Scripts/pio_late_producer.py b/tests/RP2040Sharp.IntegrationTests/Scripts/pio_late_producer.py new file mode 100644 index 0000000..e690ef3 --- /dev/null +++ b/tests/RP2040Sharp.IntegrationTests/Scripts/pio_late_producer.py @@ -0,0 +1,37 @@ +""" +Functional check: late producer with autopull. + +SM0 runs OUT PINS,8 with autopull (pull_thresh=8): with an empty TX FIFO it immediately +stalls waiting for data. The byte is written via sm0.put() ONLY AFTER SM0 is already +parked on the autopull stall. The OUT must drive the pins once the data arrives. + +We read the 8 output pins back directly to reconstruct the byte that PIO drove (the pins +hold their last driven value while SM0 re-stalls on the next autopull). + +Expected output: "late: 0x3c" +""" +import rp2 +from machine import Pin +import time + +@rp2.asm_pio(out_init=[rp2.PIO.OUT_LOW] * 8, + out_shiftdir=rp2.PIO.SHIFT_RIGHT, + autopull=True, pull_thresh=8) +def out8(): + out(pins, 8) + +sm0 = rp2.StateMachine(0, out8, freq=2_000_000, out_base=Pin(0)) +sm0.active(1) + +# Let SM0 reach the autopull stall (TX FIFO is empty) before producing any data. +time.sleep_ms(5) + +# Late producer: feed the byte only now, after SM0 is already parked on the stalled OUT. +sm0.put(0x3C) +time.sleep_ms(5) + +# Reconstruct the byte driven onto pins 0..7. +val = 0 +for i in range(8): + val |= Pin(i, Pin.IN).value() << i +print("late:", hex(val)) # expected: 0x3c diff --git a/tests/RP2040Sharp.IntegrationTests/Scripts/pio_mov_ops.py b/tests/RP2040Sharp.IntegrationTests/Scripts/pio_mov_ops.py new file mode 100644 index 0000000..47b37d3 --- /dev/null +++ b/tests/RP2040Sharp.IntegrationTests/Scripts/pio_mov_ops.py @@ -0,0 +1,39 @@ +""" +Verify the PIO MOV operators (bitwise invert and bit-reverse) against real MicroPython. + + PULL — load a word from the TX FIFO into the OSR + MOV ISR, ~OSR / rev — apply the operator on the way into the ISR + PUSH — return the result via the RX FIFO + +Expected output: + inv: 0xffff0000 (~0x0000ffff) + rev: 0x80000000 (bit-reverse of 0x00000001) +""" +import rp2 +import time + +@rp2.asm_pio() +def invert_word(): + pull(block) + mov(isr, invert(osr)) + push(block) + +@rp2.asm_pio() +def reverse_word(): + pull(block) + mov(isr, reverse(osr)) + push(block) + +sm = rp2.StateMachine(0, invert_word) +sm.active(1) +sm.put(0x0000_FFFF) +time.sleep_ms(2) +print("inv:", hex(sm.get())) # expected: 0xffff0000 +sm.active(0) + +sm2 = rp2.StateMachine(1, reverse_word) +sm2.active(1) +sm2.put(0x0000_0001) +time.sleep_ms(2) +print("rev:", hex(sm2.get())) # expected: 0x80000000 +sm2.active(0) diff --git a/tests/RP2040Sharp.IntegrationTests/Tests/EmbeddedFirmwareTests.cs b/tests/RP2040Sharp.IntegrationTests/Tests/EmbeddedFirmwareTests.cs new file mode 100644 index 0000000..b9e75f1 --- /dev/null +++ b/tests/RP2040Sharp.IntegrationTests/Tests/EmbeddedFirmwareTests.cs @@ -0,0 +1,32 @@ +using System.Reflection; + +namespace RP2040Sharp.IntegrationTests.Tests; + +/// +/// Guards that the MicroPython and CircuitPython UF2 images stay embedded in the test assembly. +/// They are bundled (under Firmware/python/) so the integration tests run fully offline — without +/// these resources, would silently fall back to network +/// downloads and reintroduce flakiness. +/// +[Trait("Category", "Integration")] +public sealed class EmbeddedFirmwareTests +{ + [Theory] + [InlineData("micropython-v1.19.1")] + [InlineData("micropython-v1.20.0")] + [InlineData("micropython-v1.21.0")] + [InlineData("circuitpython-9.2.1")] + public void Firmware_image_is_embedded(string fileNameContains) + { + var asm = Assembly.GetExecutingAssembly(); + var match = asm.GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith(".uf2", StringComparison.OrdinalIgnoreCase) + && n.Contains(fileNameContains, StringComparison.OrdinalIgnoreCase)); + + match.Should().NotBeNull($"'{fileNameContains}.uf2' must remain embedded for offline tests"); + + using var stream = asm.GetManifestResourceStream(match!); + stream.Should().NotBeNull(); + stream!.Length.Should().BeGreaterThan(100_000, "a real Python firmware UF2 is hundreds of KB"); + } +} diff --git a/tests/RP2040Sharp.IntegrationTests/Tests/MicroPythonPioTests.cs b/tests/RP2040Sharp.IntegrationTests/Tests/MicroPythonPioTests.cs index fa2a773..1eb4555 100644 --- a/tests/RP2040Sharp.IntegrationTests/Tests/MicroPythonPioTests.cs +++ b/tests/RP2040Sharp.IntegrationTests/Tests/MicroPythonPioTests.cs @@ -116,6 +116,59 @@ public async Task MicroPython_Pio_FifoLoopback_ReturnsOriginalByte() "PIO OUT PINS → IN PINS loopback must return the 0xA5 sentinel byte"); } + // ── Late producer (autopull stall regression) ──────────────────────────── + + /// + /// Functional check (against real MicroPython) of the autopull late-producer path: SM0 runs + /// OUT PINS,8 with autopull, stalls on the empty TX FIFO, and must drive the pins with the + /// byte once it is produced late via sm.put(). The strict regression that pins down the + /// PC-skip bug lives in the unit suite (LateProducerAutopull). + /// + [Fact] + public async Task MicroPython_Pio_LateProducer_AutopullStall_DrivesPins() + { + if (ShouldSkip) return; + + await using var runner = await MicroPythonRunner.CreateAsync(Version); + if (runner is null) return; + + runner.WaitForPrompt().Should().BeTrue(); + + var script = LoadScript("pio_late_producer"); + runner.WriteFile("main.py", script).Should().BeTrue(); + + runner.SoftReset(timeoutMs: 25_000).Should().BeTrue(); + + var text = runner.UsbCdc.IsConnected ? runner.UsbCdc.Text : runner.Uart.Text; + text.Should().Contain("late: 0x3c", + "an OUT stalled on autopull must drive the pins once the byte is produced late"); + } + + // ── MOV operators (invert / bit-reverse) ────────────────────────────────── + + /// + /// Verify the PIO MOV invert and bit-reverse operators end-to-end via real MicroPython. + /// + [Fact] + public async Task MicroPython_Pio_MovOperators_InvertAndReverse() + { + if (ShouldSkip) return; + + await using var runner = await MicroPythonRunner.CreateAsync(Version); + if (runner is null) return; + + runner.WaitForPrompt().Should().BeTrue(); + + var script = LoadScript("pio_mov_ops"); + runner.WriteFile("main.py", script).Should().BeTrue(); + + runner.SoftReset(timeoutMs: 25_000).Should().BeTrue(); + + var text = runner.UsbCdc.IsConnected ? runner.UsbCdc.Text : runner.Uart.Text; + text.Should().Contain("inv: 0xffff0000", "MOV ISR, ~OSR must bitwise-invert the word"); + text.Should().Contain("rev: 0x80000000", "MOV ISR, ::OSR must bit-reverse the word"); + } + // ── REPL-based smoke test ───────────────────────────────────────────────── /// diff --git a/tests/RP2040Sharp.Tests/Memory/RandomAccessMemoryTests.cs b/tests/RP2040Sharp.Tests/Memory/RandomAccessMemoryTests.cs new file mode 100644 index 0000000..5c7c649 --- /dev/null +++ b/tests/RP2040Sharp.Tests/Memory/RandomAccessMemoryTests.cs @@ -0,0 +1,89 @@ +using FluentAssertions; +using RP2040.Core.Memory; +using RP2040.Peripherals; +using Xunit; + +namespace RP2040.Peripherals.Tests.Memory; + +/// +/// Tests for , whose backing store is unmanaged native memory. +/// Verifies zero-initialisation, read/write correctness, idempotent disposal, and that creating +/// and disposing many machines does not leak managed heap (the RAM lives outside the GC heap). +/// +public sealed class RandomAccessMemoryTests +{ + [Fact] + public void AllocZeroed_starts_at_zero() + { + using var ram = new RandomAccessMemory(1024); + for (uint a = 0; a < 1024; a += 4) + ram.ReadWord(a).Should().Be(0u, "native memory is zero-initialised like new byte[]"); + } + + [Fact] + public void ReadWrite_byte_half_word_roundtrip() + { + using var ram = new RandomAccessMemory(64); + + ram.WriteByte(0, 0xAB); + ram.ReadByte(0).Should().Be(0xAB); + + ram.WriteHalfWord(2, 0xBEEF); + ram.ReadHalfWord(2).Should().Be(0xBEEF); + + ram.WriteWord(8, 0xDEADBEEF); + ram.ReadWord(8).Should().Be(0xDEADBEEF); + + // Word write is visible byte-wise (little-endian). + ram.ReadByte(8).Should().Be(0xEF); + ram.ReadByte(11).Should().Be(0xDE); + } + + [Fact] + public void Size_reflects_constructor_argument() + { + using var ram = new RandomAccessMemory(512 * 1024); + ram.Size.Should().Be(512u * 1024u); + } + + [Fact] + public void Dispose_is_idempotent() + { + var ram = new RandomAccessMemory(256); + ram.WriteWord(0, 1u); + + var act = () => + { + ram.Dispose(); + ram.Dispose(); // second free must be a no-op, not a double-free crash + }; + act.Should().NotThrow("double Dispose must be safe (guarded by _disposed)"); + } + + [Fact] + public void Creating_and_disposing_many_machines_does_not_grow_managed_heap() + { + // Each RP2040Machine allocates ~2.5 MB of RAM. Before the NativeMemory migration this was a + // pinned managed array; creating+disposing 200 machines would have churned ~500 MB of pinned + // managed heap. Now the RAM is unmanaged, so the managed heap stays flat. + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + var before = GC.GetTotalMemory(forceFullCollection: true); + + for (var i = 0; i < 200; i++) + { + var m = new RP2040Machine(); + m.Dispose(); + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + var after = GC.GetTotalMemory(forceFullCollection: true); + + // Allow generous slack for unrelated allocations; the point is it is NOT ~500 MB. + (after - before).Should().BeLessThan(32 * 1024 * 1024, + "RAM blocks are unmanaged now, so 200 machines must not balloon the managed heap"); + } +} diff --git a/tests/RP2040Sharp.Tests/Pio/PioExhaustiveTests.cs b/tests/RP2040Sharp.Tests/Pio/PioExhaustiveTests.cs new file mode 100644 index 0000000..9035ecf --- /dev/null +++ b/tests/RP2040Sharp.Tests/Pio/PioExhaustiveTests.cs @@ -0,0 +1,858 @@ +using RP2040.Core.Cpu; +using RP2040.Core.Memory; +using RP2040.Peripherals.Pio; + +namespace RP2040.Peripherals.Tests.Pio; + +/// +/// Exhaustive coverage of the PIO instruction set, FIFOs, shift logic, autopull/autopush, +/// side-set, wrap, clock divider, multi-SM independence and NVIC interrupt routing. +/// +/// Scratch registers are observed non-invasively via the SMx_INSTR forced-execution path +/// (force MOV ISR, <reg> then force PUSH, then read RXFx). +/// +public sealed class PioExhaustiveTests +{ + // ── Register map ───────────────────────────────────────────────────────── + private const uint CTRL = 0x000; + private const uint FSTAT = 0x004; + private const uint FDEBUG = 0x008; + private const uint FLEVEL = 0x00C; + private const uint TXF_BASE = 0x010; + private const uint RXF_BASE = 0x020; + private const uint IRQ = 0x030; + private const uint IRQ_FORCE = 0x034; + private const uint INSTR_MEM = 0x048; + private const uint SM_BASE = 0x0C8; + private const uint SM_STRIDE = 0x18; + private const uint INTR = 0x128; + private const uint IRQ0_INTE = 0x12C; + private const uint IRQ0_INTS = 0x134; + private const uint IRQ1_INTE = 0x138; + private const uint IRQ1_INTS = 0x140; + + private static uint TXF(int sm) => TXF_BASE + (uint)sm * 4; + private static uint RXF(int sm) => RXF_BASE + (uint)sm * 4; + private static uint CLKDIV(int sm) => SM_BASE + (uint)sm * SM_STRIDE + 0x00; + private static uint EXECCTRL(int sm) => SM_BASE + (uint)sm * SM_STRIDE + 0x04; + private static uint SHIFTCTRL(int sm) => SM_BASE + (uint)sm * SM_STRIDE + 0x08; + private static uint ADDR(int sm) => SM_BASE + (uint)sm * SM_STRIDE + 0x0C; + private static uint INSTR(int sm) => SM_BASE + (uint)sm * SM_STRIDE + 0x10; + private static uint PINCTRL(int sm) => SM_BASE + (uint)sm * SM_STRIDE + 0x14; + + // ── Instruction encoders ───────────────────────────────────────────────── + private static ushort Jmp(uint cond, uint addr) => (ushort)((0 << 13) | ((cond & 7) << 5) | (addr & 0x1F)); + private static ushort Wait(uint pol, uint src, uint idx) => (ushort)((1 << 13) | ((pol & 1) << 7) | ((src & 3) << 5) | (idx & 0x1F)); + private static ushort In(uint src, uint cnt) => (ushort)((2 << 13) | ((src & 7) << 5) | (cnt & 0x1F)); + private static ushort Out(uint dst, uint cnt) => (ushort)((3 << 13) | ((dst & 7) << 5) | (cnt & 0x1F)); + private static ushort Push(bool block = true, bool ifFull = false) => (ushort)((4 << 13) | (ifFull ? 1 << 6 : 0) | (block ? 1 << 5 : 0)); + private static ushort Pull(bool block = true, bool ifEmpty = false) => (ushort)((4 << 13) | (1 << 7) | (ifEmpty ? 1 << 6 : 0) | (block ? 1 << 5 : 0)); + private static ushort Mov(uint dst, uint op, uint src) => (ushort)((5 << 13) | ((dst & 7) << 5) | ((op & 3) << 3) | (src & 7)); + private static ushort IrqInstr(uint idx, bool clear = false, bool wait = false) => (ushort)((6 << 13) | (clear ? 1 << 6 : 0) | (wait ? 1 << 5 : 0) | (idx & 0x1F)); + private static ushort Set(uint dst, uint val) => (ushort)((7 << 13) | ((dst & 7) << 5) | (val & 0x1F)); + private static ushort Nop() => Mov(2, 0, 2); // MOV Y, Y + + // dest/src codes + private const uint D_PINS = 0, D_X = 1, D_Y = 2, D_NULL = 3, D_PINDIRS = 4, D_PC = 5, D_ISR = 6, D_EXEC = 7; + private const uint MOV_PINS = 0, MOV_X = 1, MOV_Y = 2, MOV_NULL = 3, MOV_STATUS = 5, MOV_ISR = 6, MOV_OSR = 7; + private const uint OP_NONE = 0, OP_INVERT = 1, OP_REVERSE = 2; + + private sealed class Fixture : IDisposable + { + public BusInterconnect Bus { get; } + public CortexM0Plus Cpu { get; } + public PioPeripheral Pio { get; } + public uint GpioIn; + + public Fixture() + { + Bus = new BusInterconnect(); + Cpu = new CortexM0Plus(Bus); + Pio = new PioPeripheral(Cpu, 0); + Pio.ReadGpioIn = () => GpioIn; + } + + public void Dispose() => Bus.Dispose(); + + /// Load a program (slot 0..n) and configure wrap_top to the last slot. + public void Load(int sm, params ushort[] prog) + { + for (var i = 0; i < prog.Length; i++) + Pio.WriteWord(INSTR_MEM + (uint)i * 4, prog[i]); + // wrap_top = last instruction, wrap_bottom = 0 + Pio.WriteWord(EXECCTRL(sm), (uint)(prog.Length - 1) << 12); + Pio.WriteWord(CLKDIV(sm), 1u << 16); + } + + public void Enable(int sm) => Pio.WriteWord(CTRL, 1u << sm); + public void Tick(long n = 1) => Pio.Tick(n); + + /// Read scratch X non-invasively via forced MOV ISR,X → PUSH → RXF. + public uint ReadX(int sm) => ForceReadReg(sm, MOV_X); + public uint ReadY(int sm) => ForceReadReg(sm, MOV_Y); + public uint ReadOsr(int sm) => ForceReadReg(sm, MOV_OSR); + + private uint ForceReadReg(int sm, uint movSrc) + { + Pio.WriteWord(INSTR(sm), Mov(MOV_ISR, OP_NONE, movSrc)); // MOV ISR, reg + Pio.WriteWord(INSTR(sm), Push(block: true)); // PUSH + return Pio.ReadWord(RXF(sm)); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // JMP — all eight conditions + // ════════════════════════════════════════════════════════════════════════ + public class JmpInstr + { + [Fact] + public void Always_jumps() + { + using var f = new Fixture(); + f.Load(0, Jmp(0, 3), Nop(), Nop(), Nop()); + f.Enable(0); + f.Tick(1); + f.Pio.ReadWord(ADDR(0)).Should().Be(3u); + } + + [Fact] + public void NotX_taken_when_X_zero() + { + using var f = new Fixture(); + // SET X,0 ; JMP !X, 3 ; NOP ; target NOP + f.Load(0, Set(D_X, 0), Jmp(1, 3), Nop(), Nop()); + f.Enable(0); + f.Tick(2); + f.Pio.ReadWord(ADDR(0)).Should().Be(3u, "JMP !X taken when X==0"); + } + + [Fact] + public void NotX_falls_through_when_X_nonzero() + { + using var f = new Fixture(); + f.Load(0, Set(D_X, 5), Jmp(1, 3), Nop(), Nop()); + f.Enable(0); + f.Tick(2); + f.Pio.ReadWord(ADDR(0)).Should().Be(2u, "JMP !X not taken when X!=0"); + } + + [Fact] + public void XPostDec_taken_and_decrements_when_nonzero() + { + using var f = new Fixture(); + // SET X,2 ; loop: JMP X--, loop → spins until X exhausted + f.Load(0, Set(D_X, 2), Jmp(2, 1)); + f.Enable(0); + f.Tick(1); // SET X,2 + f.Tick(1); // JMP X-- (X 2→1, taken) + f.Pio.ReadWord(ADDR(0)).Should().Be(1u, "still looping"); + f.ReadX(0).Should().Be(1u, "X decremented to 1"); + } + + [Fact] + public void XPostDec_decrements_to_wraparound_when_zero() + { + using var f = new Fixture(); + // SET X,0 ; JMP X--, 0 (not taken) ; NOP + f.Load(0, Set(D_X, 0), Jmp(2, 0), Nop()); + f.Enable(0); + f.Tick(2); + f.Pio.ReadWord(ADDR(0)).Should().Be(2u, "JMP X-- not taken when X==0"); + f.ReadX(0).Should().Be(0xFFFFFFFFu, "X-- always decrements (0 → 0xFFFFFFFF)"); + } + + [Fact] + public void YPostDec_taken_when_nonzero() + { + using var f = new Fixture(); + f.Load(0, Set(D_Y, 1), Jmp(4, 3), Nop(), Nop()); + f.Enable(0); + f.Tick(2); + f.Pio.ReadWord(ADDR(0)).Should().Be(3u, "JMP Y-- taken when Y!=0"); + } + + [Fact] + public void XNeY_taken_when_different() + { + using var f = new Fixture(); + f.Load(0, Set(D_X, 1), Set(D_Y, 2), Jmp(5, 5), Nop(), Nop(), Nop()); + f.Enable(0); + f.Tick(3); + f.Pio.ReadWord(ADDR(0)).Should().Be(5u, "JMP X!=Y taken when X != Y"); + } + + [Fact] + public void XNeY_falls_through_when_equal() + { + using var f = new Fixture(); + f.Load(0, Set(D_X, 2), Set(D_Y, 2), Jmp(5, 5), Nop(), Nop(), Nop()); + f.Enable(0); + f.Tick(3); + f.Pio.ReadWord(ADDR(0)).Should().Be(3u, "JMP X!=Y not taken when X == Y"); + } + + [Fact] + public void Pin_condition_reads_input_pin() + { + using var f = new Fixture(); + // EXECCTRL.JMP_PIN = bit [28:24]; pick pin 7 + f.Load(0, Jmp(6, 3), Nop(), Nop(), Nop()); + f.Pio.WriteWord(EXECCTRL(0), (0u << 12) | (7u << 24)); // wrap_top=0 overwritten below + // restore wrap_top to 3 and keep jmp_pin=7 + f.Pio.WriteWord(EXECCTRL(0), (3u << 12) | (7u << 24)); + f.GpioIn = 1u << 7; + f.Enable(0); + f.Tick(1); + f.Pio.ReadWord(ADDR(0)).Should().Be(3u, "JMP PIN taken when JMP_PIN input is high"); + } + + [Fact] + public void NotOsre_taken_when_osr_not_empty() + { + using var f = new Fixture(); + f.Pio.WriteWord(TXF(0), 0x1234u); + f.Load(0, Pull(block: true), Jmp(7, 1)); + f.Enable(0); + f.Tick(2); + f.Pio.ReadWord(ADDR(0)).Should().Be(1u, "JMP !OSRE taken when OSR has data"); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // SET — pins/dirs/X/Y with base & count + // ════════════════════════════════════════════════════════════════════════ + public class SetInstr + { + [Fact] + public void Set_X_then_Y() + { + using var f = new Fixture(); + f.Load(0, Set(D_X, 21), Set(D_Y, 10)); + f.Enable(0); + f.Tick(2); + f.ReadX(0).Should().Be(21u); + f.ReadY(0).Should().Be(10u); + } + + [Fact] + public void Set_pins_uses_set_base_and_count() + { + using var f = new Fixture(); + uint pins = 0, mask = 0; + f.Pio.WriteGpioPins = (v, m) => { pins = v; mask = m; }; + // PINCTRL: SET_COUNT=3 (bits[28:26]), SET_BASE=4 (bits[9:5]) + f.Pio.WriteWord(PINCTRL(0), (3u << 26) | (4u << 5)); + f.Load(0, Set(D_PINS, 0b101)); + f.Enable(0); + f.Tick(1); + mask.Should().Be(0b111u << 4, "3 pins at base 4"); + pins.Should().Be(0b101u << 4, "value 0b101 shifted to base 4"); + } + + [Fact] + public void Set_pindirs_drives_dirs() + { + using var f = new Fixture(); + uint dirs = 0, mask = 0; + f.Pio.WriteGpioDirs = (v, m) => { dirs = v; mask = m; }; + f.Pio.WriteWord(PINCTRL(0), (2u << 26) | (0u << 5)); // count 2, base 0 + f.Load(0, Set(D_PINDIRS, 0b11)); + f.Enable(0); + f.Tick(1); + mask.Should().Be(0b11u); + dirs.Should().Be(0b11u); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // MOV — sources, ops, destinations + // ════════════════════════════════════════════════════════════════════════ + public class MovInstr + { + [Fact] + public void Mov_X_to_Y() + { + using var f = new Fixture(); + f.Load(0, Set(D_X, 13), Mov(MOV_Y, OP_NONE, MOV_X)); + f.Enable(0); + f.Tick(2); + f.ReadY(0).Should().Be(13u); + } + + [Fact] + public void Mov_invert() + { + using var f = new Fixture(); + f.Load(0, Set(D_X, 0), Mov(MOV_Y, OP_INVERT, MOV_X)); + f.Enable(0); + f.Tick(2); + f.ReadY(0).Should().Be(0xFFFFFFFFu, "MOV ~X with X=0 → all ones"); + } + + [Fact] + public void Mov_bit_reverse() + { + using var f = new Fixture(); + // X = 1 → reverse → 0x80000000 + f.Load(0, Set(D_X, 1), Mov(MOV_Y, OP_REVERSE, MOV_X)); + f.Enable(0); + f.Tick(2); + f.ReadY(0).Should().Be(0x80000000u, "bit-reverse of 1 is 0x80000000"); + } + + [Fact] + public void Mov_pins_reads_gpio_input() + { + using var f = new Fixture(); + f.GpioIn = 0xCAFEu; + f.Load(0, Mov(MOV_X, OP_NONE, MOV_PINS)); + f.Enable(0); + f.Tick(1); + f.ReadX(0).Should().Be(0xCAFEu, "MOV X, PINS reads physical GPIO input"); + } + + [Fact] + public void Mov_status_all_ones_when_below_threshold() + { + using var f = new Fixture(); + f.Load(0, Mov(MOV_X, OP_NONE, MOV_STATUS), Nop()); + // EXECCTRL STATUS_SEL=0 (TX), STATUS_N=1 → MOV STATUS is all-ones when TX level < 1 (empty). + // Set after Load (which would otherwise overwrite EXECCTRL with just wrap_top). + f.Pio.WriteWord(EXECCTRL(0), (1u << 12) | (0u << 4) | 1u); + f.Enable(0); + f.Tick(1); + f.ReadX(0).Should().Be(0xFFFFFFFFu, "STATUS all-ones when TX FIFO level < STATUS_N"); + } + + [Fact] + public void Mov_status_zero_when_at_or_above_threshold() + { + using var f = new Fixture(); + f.Load(0, Mov(MOV_X, OP_NONE, MOV_STATUS), Nop()); + f.Pio.WriteWord(EXECCTRL(0), (1u << 12) | (0u << 4) | 1u); // TX, N=1 (after Load) + f.Pio.WriteWord(TXF(0), 0xAAAAu); // TX level = 1 ≥ N + f.Enable(0); + f.Tick(1); + f.ReadX(0).Should().Be(0u, "STATUS zero when TX FIFO level ≥ STATUS_N"); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // IN / OUT — sources, destinations, shift directions, threshold + // ════════════════════════════════════════════════════════════════════════ + public class InOut + { + [Fact] + public void In_pins_shift_left_then_push() + { + using var f = new Fixture(); + f.GpioIn = 0xAB; + // IN PINS,8 (left shift default) ; PUSH + f.Load(0, In(0, 8), Push(block: true)); + f.Enable(0); + f.Tick(2); + f.Pio.ReadWord(RXF(0)).Should().Be(0xABu); + } + + [Fact] + public void In_x_source() + { + using var f = new Fixture(); + f.Load(0, Set(D_X, 0x1F), In(1 /*X*/, 5), Push(block: true)); + f.Enable(0); + f.Tick(3); + f.Pio.ReadWord(RXF(0)).Should().Be(0x1Fu); + } + + [Fact] + public void In_shift_right_places_bits_at_top() + { + using var f = new Fixture(); + f.GpioIn = 0xFF; + // SHIFTCTRL bit18 = ISR shift right + f.Pio.WriteWord(SHIFTCTRL(0), 1u << 18); + f.Load(0, In(0, 8), Push(block: true)); + f.Enable(0); + f.Tick(2); + f.Pio.ReadWord(RXF(0)).Should().Be(0xFF000000u, "right-shift puts 8 bits at the MSB end"); + } + + [Fact] + public void Out_to_X_left_shift() + { + using var f = new Fixture(); + f.Pio.WriteWord(TXF(0), 0xABCD1234u); + // PULL ; OUT X,16 (left shift default → top 16 bits) + f.Load(0, Pull(block: true), Out(D_X, 16)); + f.Enable(0); + f.Tick(2); + f.ReadX(0).Should().Be(0xABCDu, "left-shift OUT 16 takes the top 16 bits first"); + } + + [Fact] + public void Out_to_Y_right_shift() + { + using var f = new Fixture(); + f.Pio.WriteWord(TXF(0), 0xABCD1234u); + f.Pio.WriteWord(SHIFTCTRL(0), 1u << 19); // OSR shift right + f.Load(0, Pull(block: true), Out(D_Y, 16)); + f.Enable(0); + f.Tick(2); + f.ReadY(0).Should().Be(0x1234u, "right-shift OUT 16 takes the low 16 bits first"); + } + + [Fact] + public void Out_pindirs() + { + using var f = new Fixture(); + uint dirs = 0, mask = 0; + f.Pio.WriteGpioDirs = (v, m) => { dirs = v; mask = m; }; + f.Pio.WriteWord(TXF(0), 0xFFu); + f.Pio.WriteWord(SHIFTCTRL(0), 1u << 19); // shift right + f.Load(0, Pull(block: true), Out(D_PINDIRS, 4)); + f.Enable(0); + f.Tick(2); + mask.Should().Be(0xFu); + dirs.Should().Be(0xFu); + } + + [Fact] + public void Out_to_PC_jumps() + { + using var f = new Fixture(); + f.Pio.WriteWord(TXF(0), 5u); + f.Pio.WriteWord(SHIFTCTRL(0), 1u << 19); // shift right + f.Load(0, Pull(block: true), Out(D_PC, 5), Nop(), Nop(), Nop(), Nop()); + f.Enable(0); + f.Tick(2); + f.Pio.ReadWord(ADDR(0)).Should().Be(5u, "OUT PC sets the program counter"); + } + + [Fact] + public void Out_exec_runs_embedded_instruction() + { + using var f = new Fixture(); + // Put a SET X,7 instruction word into the OSR, then OUT EXEC executes it. + uint setX7 = Set(D_X, 7); + f.Pio.WriteWord(TXF(0), setX7); + f.Pio.WriteWord(SHIFTCTRL(0), 1u << 19); // shift right (low 16 bits = instruction) + f.Load(0, Pull(block: true), Out(D_EXEC, 16), Nop()); + f.Enable(0); + f.Tick(2); + f.ReadX(0).Should().Be(7u, "OUT EXEC executes the SET X,7 carried in the OSR"); + } + + [Fact] + public void Out_null_discards_and_consumes_osr() + { + using var f = new Fixture(); + f.Pio.WriteWord(TXF(0), 0xFFFFFFFFu); + f.Pio.WriteWord(SHIFTCTRL(0), 1u << 19); // shift right + // PULL ; OUT NULL,16 ; JMP !OSRE,target (16 bits still remain → taken) + f.Load(0, Pull(block: true), Out(D_NULL, 16), Jmp(7, 4), Nop(), Nop()); + f.Enable(0); + f.Tick(3); + f.Pio.ReadWord(ADDR(0)).Should().Be(4u, "16 bits still remain in OSR after OUT NULL,16"); + f.ReadOsr(0).Should().Be(0x0000FFFFu, "right-shift consumed low 16 bits, 0xFFFF remains"); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // PUSH / PULL — block, noblock, iffull, ifempty, FDEBUG + // ════════════════════════════════════════════════════════════════════════ + public class PushPull + { + [Fact] + public void Pull_block_stalls_on_empty_and_sets_TXSTALL() + { + using var f = new Fixture(); + f.Load(0, Pull(block: true)); + f.Enable(0); + f.Tick(3); + ((f.Pio.ReadWord(FDEBUG) >> 24) & 1).Should().Be(1u, "TXSTALL [27:24] set"); + f.Pio.ReadWord(ADDR(0)).Should().Be(0u, "PC stays on the stalled PULL"); + } + + [Fact] + public void Pull_noblock_on_empty_copies_X_to_OSR() + { + using var f = new Fixture(); + // SET X,9 ; PULL noblock (empty → OSR = X) + f.Load(0, Set(D_X, 9), Pull(block: false), Nop()); + f.Enable(0); + f.Tick(2); + f.ReadOsr(0).Should().Be(9u, "PULL NOBLOCK on empty FIFO copies X into OSR"); + } + + [Fact] + public void Pull_ifempty_skips_when_osr_not_empty() + { + using var f = new Fixture(); + f.Pio.WriteWord(TXF(0), 0x1u); + f.Pio.WriteWord(TXF(0), 0x2u); + f.Pio.WriteWord(SHIFTCTRL(0), 1u << 19); // shift right + // PULL ; OUT NULL,16 (OSR not empty) ; PULL ifempty (should NOT pull) ; loop + f.Load(0, Pull(block: true), Out(D_NULL, 16), Pull(block: true, ifEmpty: true), Nop()); + f.Enable(0); + f.Tick(3); + // Only the first PULL consumed a word → one word still in TX FIFO + (f.Pio.ReadWord(FLEVEL) & 0xF).Should().Be(1u, "PULL IFEMPTY must not consume when OSR not empty"); + } + + [Fact] + public void Push_block_stalls_on_full_and_sets_RXSTALL() + { + using var f = new Fixture(); + for (uint i = 0; i < 4; i++) f.Pio.InjectRxData(0, i); // fill RX FIFO + f.Load(0, Push(block: true)); + f.Enable(0); + f.Tick(3); + (f.Pio.ReadWord(FDEBUG) & 1u).Should().Be(1u, "RXSTALL [3:0] set on blocked PUSH"); + } + + [Fact] + public void Push_iffull_skips_below_threshold() + { + using var f = new Fixture(); + // autopush threshold 32; PUSH IFFULL with IsrCount<32 should be a no-op + f.Pio.WriteWord(SHIFTCTRL(0), 0u); + f.Load(0, Push(block: true, ifFull: true), Nop()); + f.Enable(0); + f.Tick(1); + f.Pio.RxFifoEmpty(0).Should().BeTrue("PUSH IFFULL below threshold pushes nothing"); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // IRQ instruction + WAIT IRQ + NVIC routing + // ════════════════════════════════════════════════════════════════════════ + public class IrqAndWait + { + [Fact] + public void Irq_set_raises_flag_in_INTR() + { + using var f = new Fixture(); + f.Load(0, IrqInstr(0), Nop()); + f.Enable(0); + f.Tick(1); + // INTR bits [11:8] are IRQ flags 0-3 + ((f.Pio.ReadWord(INTR) >> 8) & 1).Should().Be(1u, "IRQ 0 flag raised"); + } + + [Fact] + public void Irq_clear_lowers_flag() + { + using var f = new Fixture(); + f.Load(0, IrqInstr(0), IrqInstr(0, clear: true), Nop()); + f.Enable(0); + f.Tick(2); + ((f.Pio.ReadWord(INTR) >> 8) & 1).Should().Be(0u, "IRQ 0 cleared"); + } + + [Fact] + public void Wait_irq_stalls_until_flag_set_then_clears_it() + { + using var f = new Fixture(); + // SM1 sets IRQ4; SM0 waits on IRQ4 (polarity 1) then proceeds. + // Use IRQ index 4 to avoid REL ambiguity. + f.Pio.WriteWord(INSTR_MEM + 0, Wait(1, 2 /*IRQ*/, 4)); // slot0 SM0 + f.Pio.WriteWord(INSTR_MEM + 4, Jmp(0, 1)); // slot1 SM0 loop + f.Pio.WriteWord(EXECCTRL(0), 1u << 12); + f.Pio.WriteWord(CLKDIV(0), 1u << 16); + f.Enable(0); + + f.Tick(2); + f.Pio.ReadWord(ADDR(0)).Should().Be(0u, "SM0 stalls on WAIT IRQ4 while flag clear"); + + // Raise IRQ4 via force register + f.Pio.WriteWord(IRQ_FORCE, 1u << 4); + f.Tick(2); + f.Pio.ReadWord(ADDR(0)).Should().Be(1u, "SM0 proceeds once IRQ4 is set"); + ((f.Pio.ReadWord(IRQ) >> 4) & 1).Should().Be(0u, "WAIT 1 IRQ clears the flag on success"); + } + + [Fact] + public void Nvic_irq0_asserted_when_inte_enabled_and_flag_set() + { + using var f = new Fixture(); + // Enable IRQ0 on SM IRQ flag 0 (INTR bit 8 → IRQ0_INTE bit 8) + f.Pio.WriteWord(IRQ0_INTE, 1u << 8); + f.Load(0, IrqInstr(0), Nop()); + f.Enable(0); + f.Tick(1); + ((f.Pio.ReadWord(IRQ0_INTS) >> 8) & 1).Should().Be(1u, "IRQ0_INTS reflects masked flag"); + (f.Cpu.Registers.PendingInterrupts & (1u << 7)).Should().NotBe(0u, "PIO0_IRQ0 = NVIC IRQ 7 must be asserted"); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // WAIT GPIO / PIN + // ════════════════════════════════════════════════════════════════════════ + public class WaitPins + { + [Fact] + public void Wait_gpio_high_stalls_until_pin_high() + { + using var f = new Fixture(); + f.GpioIn = 0; + f.Load(0, Wait(1, 0 /*GPIO*/, 5), Nop()); + f.Enable(0); + f.Tick(2); + f.Pio.ReadWord(ADDR(0)).Should().Be(0u, "stalled while GPIO5 low"); + f.GpioIn = 1u << 5; + f.Tick(1); + f.Pio.ReadWord(ADDR(0)).Should().Be(1u, "proceeds once GPIO5 high"); + } + + [Fact] + public void Wait_gpio_low_polarity0() + { + using var f = new Fixture(); + f.GpioIn = 1u << 3; + f.Load(0, Wait(0, 0 /*GPIO*/, 3), Nop()); + f.Enable(0); + f.Tick(2); + f.Pio.ReadWord(ADDR(0)).Should().Be(0u, "stalled while GPIO3 high (waiting for low)"); + f.GpioIn = 0; + f.Tick(1); + f.Pio.ReadWord(ADDR(0)).Should().Be(1u, "proceeds once GPIO3 low"); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Autopull / autopush + // ════════════════════════════════════════════════════════════════════════ + public class AutoShift + { + [Fact] + public void Autopull_refills_osr_each_threshold() + { + using var f = new Fixture(); + uint a = 0, b = 0; var n = 0; + f.Pio.WriteGpioPins = (v, _) => { if (n++ == 0) a = v; else b = v; }; + f.Pio.WriteWord(TXF(0), 0x11111111u); + f.Pio.WriteWord(TXF(0), 0x22222222u); + // autopull enabled, threshold 32, shift right + f.Pio.WriteWord(SHIFTCTRL(0), (1u << 17) | (1u << 19)); + f.Load(0, Out(D_PINS, 0)); // OUT PINS,32 looping at 0 + f.Enable(0); + f.Tick(2); + a.Should().Be(0x11111111u); + b.Should().Be(0x22222222u, "autopull refilled OSR from TX FIFO"); + } + + [Fact] + public void Autopush_threshold_pushes_to_rx_fifo() + { + using var f = new Fixture(); + f.GpioIn = 0xA5; + // autopush enabled, threshold 8, shift left + f.Pio.WriteWord(SHIFTCTRL(0), (1u << 16) | (8u << 20)); + f.Load(0, In(0, 8)); // IN PINS,8 → autopush at 8 bits + f.Enable(0); + f.Tick(1); + f.Pio.RxFifoEmpty(0).Should().BeFalse("autopush enqueued at threshold"); + f.Pio.ReadWord(RXF(0)).Should().Be(0xA5u); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // FIFO joining + // ════════════════════════════════════════════════════════════════════════ + public class FifoJoin + { + [Fact] + public void Join_rx_gives_8_deep_rx() + { + using var f = new Fixture(); + f.Pio.WriteWord(SHIFTCTRL(0), 1u << 30); // FJOIN_RX + for (uint i = 0; i < 8; i++) f.Pio.InjectRxData(0, i); + ((f.Pio.ReadWord(FLEVEL) >> 4) & 0xF).Should().Be(8u, "RX FIFO is 8 deep with FJOIN_RX"); + } + + [Fact] + public void Join_tx_disables_rx() + { + using var f = new Fixture(); + f.Pio.WriteWord(SHIFTCTRL(0), 1u << 31); // FJOIN_TX + f.Pio.InjectRxData(0, 1); // RX depth 0 → rejected + f.Pio.RxFifoEmpty(0).Should().BeTrue("RX FIFO disabled under FJOIN_TX"); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Clock divider + // ════════════════════════════════════════════════════════════════════════ + public class ClockDivider + { + [Fact] + public void Integer_divisor_slows_execution() + { + using var f = new Fixture(); + // div = 4 ; with 4 ticks, exactly one instruction step + f.Pio.WriteWord(INSTR_MEM, Jmp(0, 1)); + f.Pio.WriteWord(INSTR_MEM + 4, Jmp(0, 1)); // slot1 loops + f.Pio.WriteWord(EXECCTRL(0), 1u << 12); // wrap_top=1 + f.Pio.WriteWord(CLKDIV(0), 4u << 16); // integer divisor 4 + f.Enable(0); + f.Tick(3); + f.Pio.ReadWord(ADDR(0)).Should().Be(0u, "no step yet after 3 of 4 sub-cycles"); + f.Tick(1); + f.Pio.ReadWord(ADDR(0)).Should().Be(1u, "one step after the 4th sub-cycle"); + } + + [Fact] + public void Fractional_divisor_accumulates() + { + using var f = new Fixture(); + f.Pio.WriteWord(INSTR_MEM, Jmp(0, 1)); + f.Pio.WriteWord(INSTR_MEM + 4, Jmp(0, 1)); + f.Pio.WriteWord(EXECCTRL(0), 1u << 12); + // div = 1.5 → integer=1 frac=128 → divisor=384 (per 256-scaled accumulator) + f.Pio.WriteWord(CLKDIV(0), (1u << 16) | (128u << 8)); + f.Enable(0); + // 3 ticks → accum 768 / 384 = 2 steps + f.Tick(3); + // 2 steps from slot0→1→(wrap)→... we mainly assert it advanced without crashing + f.Pio.ReadWord(ADDR(0)).Should().BeOneOf(0u, 1u); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Wrap + // ════════════════════════════════════════════════════════════════════════ + public class Wrap + { + [Fact] + public void Wraps_from_top_to_bottom() + { + using var f = new Fixture(); + // wrap_top=2, wrap_bottom=1 + f.Pio.WriteWord(INSTR_MEM + 0, Nop()); + f.Pio.WriteWord(INSTR_MEM + 4, Nop()); + f.Pio.WriteWord(INSTR_MEM + 8, Nop()); + f.Pio.WriteWord(EXECCTRL(0), (2u << 12) | (1u << 7)); // top=2 bottom=1 + f.Pio.WriteWord(CLKDIV(0), 1u << 16); + f.Enable(0); + f.Tick(1); // PC 0→1 + f.Tick(1); // 1→2 + f.Tick(1); // 2→ wrap to 1 + f.Pio.ReadWord(ADDR(0)).Should().Be(1u, "PC wraps from top(2) to bottom(1)"); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Multi-SM independence + // ════════════════════════════════════════════════════════════════════════ + public class MultiSm + { + [Fact] + public void Two_sms_run_independently() + { + using var f = new Fixture(); + // Shared instruction memory: slot0 SET X,5 ; slot1 SET X,9 + f.Pio.WriteWord(INSTR_MEM + 0, Set(D_X, 5)); + f.Pio.WriteWord(INSTR_MEM + 4, Set(D_X, 9)); + // SM0 wraps at 0 (runs SET X,5), SM1 starts at 1 wrapping at 1 (runs SET X,9) + f.Pio.WriteWord(EXECCTRL(0), 0u << 12); + f.Pio.WriteWord(CLKDIV(0), 1u << 16); + f.Pio.WriteWord(EXECCTRL(1), (1u << 12) | (1u << 7)); // top=1 bottom=1 + f.Pio.WriteWord(CLKDIV(1), 1u << 16); + // Force SM1 PC to 1 by writing a JMP via INSTR (immediate) + f.Pio.WriteWord(INSTR(1), Jmp(0, 1)); + f.Pio.WriteWord(CTRL, 0b11); // enable both + f.Tick(3); + f.ReadX(0).Should().Be(5u, "SM0 ran SET X,5"); + f.ReadX(1).Should().Be(9u, "SM1 ran SET X,9 independently"); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Regression: late producer + autopull stall (CheckSmWait must not skip OUT) + // ════════════════════════════════════════════════════════════════════════ + public class LateProducerAutopull + { + [Fact] + public void Out_stalled_on_autopull_completes_when_data_arrives_late() + { + using var f = new Fixture(); + uint captured = 0; var calls = 0; + f.Pio.WriteGpioPins = (v, _) => { captured = v; calls++; }; + + // autopull enabled, threshold 32, shift right + f.Pio.WriteWord(SHIFTCTRL(0), (1u << 17) | (1u << 19)); + // [0] OUT PINS,32 [1] JMP 1 (halt loop). wrap_top=1. + f.Pio.WriteWord(INSTR_MEM + 0, Out(D_PINS, 0)); + f.Pio.WriteWord(INSTR_MEM + 4, Jmp(0, 1)); + f.Pio.WriteWord(EXECCTRL(0), 1u << 12); + f.Pio.WriteWord(CLKDIV(0), 1u << 16); + f.Enable(0); + + // First tick: OUT stalls (autopull, TX FIFO empty) + f.Tick(1); + calls.Should().Be(0, "no pin write while stalled"); + f.Pio.ReadWord(ADDR(0)).Should().Be(0u, "PC parked on the stalled OUT"); + + // Producer writes data late → must wake and DRIVE the pins, not skip the OUT. + f.Pio.WriteWord(TXF(0), 0xABCD1234u); + f.Tick(2); + + captured.Should().Be(0xABCD1234u, + "the OUT that stalled on autopull must drive the pins once data arrives — not be skipped"); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Regression: FDEBUG TXOVER / RXUNDER bit positions (TRM §3.7) + // ════════════════════════════════════════════════════════════════════════ + public class FdebugLayout + { + [Fact] + public void Txover_sets_bit_in_19_16_range() + { + using var f = new Fixture(); + for (uint i = 0; i < 5; i++) f.Pio.WriteWord(TXF(0), i); // overflow on the 5th + var fdebug = f.Pio.ReadWord(FDEBUG); + ((fdebug >> 16) & 1).Should().Be(1u, "TXOVER for SM0 is bit 16 ([19:16])"); + ((fdebug >> 8) & 1).Should().Be(0u, "bit 8 ([11:8]) is RXUNDER, not TXOVER"); + } + + [Fact] + public void Rxunder_sets_bit_in_11_8_range_on_empty_read() + { + using var f = new Fixture(); + f.Pio.ReadWord(RXF(0)); // read empty RX FIFO → underflow + ((f.Pio.ReadWord(FDEBUG) >> 8) & 1).Should().Be(1u, "RXUNDER for SM0 is bit 8 ([11:8])"); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Regression: autopush on full RX FIFO must not silently discard data + // ════════════════════════════════════════════════════════════════════════ + public class AutopushOverflow + { + [Fact] + public void Autopush_when_full_retains_data_and_stalls() + { + using var f = new Fixture(); + f.GpioIn = 0xDEADBEEF; + // Fill RX FIFO (4 deep) + for (uint i = 0; i < 4; i++) f.Pio.InjectRxData(0, 0x1000u + i); + // autopush threshold 32, shift left + f.Pio.WriteWord(SHIFTCTRL(0), (1u << 16) | (0u << 20)); + f.Load(0, In(0, 0)); // IN PINS,32 → autopush; RX full → must stall (not discard) + f.Enable(0); + f.Tick(2); + + // SM must be stalled with RXSTALL set, data still pending (not lost) + (f.Pio.ReadWord(FDEBUG) & 1u).Should().Be(1u, "RXSTALL set when autopush blocked by full RX FIFO"); + + // Drain the four pre-filled words + for (var i = 0; i < 4; i++) f.Pio.ReadWord(RXF(0)); + // Now the previously-stalled autopush should complete on the next tick(s) + f.Tick(2); + f.Pio.ReadWord(RXF(0)).Should().Be(0xDEADBEEFu, + "the captured value must reach the RX FIFO once space frees up — never silently dropped"); + } + } +}