diff --git a/src/RP2040Sharp/Peripherals/Psm/PsmPeripheral.cs b/src/RP2040Sharp/Peripherals/Psm/PsmPeripheral.cs
index 9d48d26..cac7c2f 100644
--- a/src/RP2040Sharp/Peripherals/Psm/PsmPeripheral.cs
+++ b/src/RP2040Sharp/Peripherals/Psm/PsmPeripheral.cs
@@ -16,10 +16,23 @@ public sealed class PsmPeripheral : IMemoryMappedDevice
// All 17 subsystem bits (proc0..spi1)
private const uint ALL_BITS = 0x0001FFFF;
+ // FRCE_OFF bit 16 = PROC1 (force Core 1 into its power-on reset state).
+ private const uint PROC1_BIT = 1u << 16;
+
private uint _frceOn;
private uint _frceOff;
private uint _wdsel;
+ ///
+ /// Raised when firmware releases Core 1 from forced-off state, i.e. clears the PROC1
+ /// bit of FRCE_OFF after having set it. On real silicon this brings Core 1 out of
+ /// reset so it re-runs its bootrom. pico-sdk's multicore_reset_core1() — called
+ /// by MicroPython before every _thread.start_new_thread — does exactly this and
+ /// then blocks on the FIFO for Core 1's "ready" word, so
+ /// hooks this to reset Core 1 and inject that word.
+ ///
+ public Action? OnProc1Released;
+
public uint Size => 0x1000;
public uint ReadWord(uint address) => address switch
@@ -42,7 +55,15 @@ public void WriteWord(uint address, uint value)
switch (address)
{
case FRCE_ON: _frceOn = value & ALL_BITS; break;
- case FRCE_OFF: _frceOff = value & ALL_BITS; break;
+ case FRCE_OFF:
+ {
+ var previous = _frceOff;
+ _frceOff = value & ALL_BITS;
+ // PROC1 going set → clear releases Core 1 from reset (multicore_reset_core1).
+ if ((previous & PROC1_BIT) != 0 && (_frceOff & PROC1_BIT) == 0)
+ OnProc1Released?.Invoke();
+ break;
+ }
case WDSEL: _wdsel = value & ALL_BITS; break;
}
}
diff --git a/src/RP2040Sharp/Peripherals/RP2040Machine.cs b/src/RP2040Sharp/Peripherals/RP2040Machine.cs
index 1e93752..56a1978 100644
--- a/src/RP2040Sharp/Peripherals/RP2040Machine.cs
+++ b/src/RP2040Sharp/Peripherals/RP2040Machine.cs
@@ -152,6 +152,9 @@ public RP2040Machine(uint flashSize = 2 * 1024 * 1024,
// PSM @ 0x40010000 (slot 4)
Psm = new PsmPeripheral();
+ // Firmware releasing PROC1 from FRCE_OFF is multicore_reset_core1(): reset Core 1
+ // and signal its bootrom is ready so Core 0's blocking FIFO pop returns.
+ Psm.OnProc1Released = ResetCore1FromPsm;
apb.Register(0x40010000, Psm);
// IO_BANK0 @ 0x40014000 (slot 5)
@@ -967,6 +970,24 @@ private void LaunchCore1(uint vtor, uint sp, uint entry)
_core1Launched = true;
}
+ ///
+ /// Invoked when firmware releases PROC1 from .FRCE_OFF, i.e.
+ /// calls pico-sdk's multicore_reset_core1() (MicroPython does this before every
+ /// _thread.start_new_thread). On real silicon Core 1 leaves reset, re-runs its
+ /// bootrom and pushes a "ready" word to Core 0, which is blocked in a FIFO pop. We
+ /// reproduce that here: stop and reset Core 1, clear the SIO launch handshake so the
+ /// following §2.8.3 sequence re-launches it, and inject the ready word to unblock Core 0.
+ /// Without this the register write is inert, Core 1 never signals, and Core 0 sleeps in
+ /// WFE forever.
+ ///
+ private void ResetCore1FromPsm()
+ {
+ _core1Launched = false;
+ Sio.ResetMulticoreLaunch();
+ Cpu1.Reset();
+ Sio.SignalCore1BootromReady();
+ }
+
// ── Per-core PPB router ───────────────────────────────────────────
///
diff --git a/src/RP2040Sharp/Peripherals/Sio/SioPeripheral.cs b/src/RP2040Sharp/Peripherals/Sio/SioPeripheral.cs
index 8c8012b..b22d409 100644
--- a/src/RP2040Sharp/Peripherals/Sio/SioPeripheral.cs
+++ b/src/RP2040Sharp/Peripherals/Sio/SioPeripheral.cs
@@ -629,6 +629,26 @@ public void ResetMulticoreLaunch()
_launchSeqPos = 0;
}
+ ///
+ /// Simulate Core 1's bootrom reporting "ready" after it is released from a PSM reset
+ /// (multicore_reset_core1()). On real silicon Core 1 comes out of reset, drains
+ /// its own inbound mailbox, then pushes a single 0 to Core 0. We reproduce that: clear
+ /// Core 1's RX FIFO and status flags, enqueue the 0 into Core 0's RX FIFO, and raise an
+ /// event so Core 0's blocking multicore_fifo_pop_blocking()/WFE returns.
+ ///
+ public void SignalCore1BootromReady()
+ {
+ // Core 1 drains its own inbound mailbox (Core0→Core1) as it reboots.
+ _fifo01.Clear();
+ _wof1 = _roe1 = false;
+
+ // Push the "ready" sentinel (0) into Core 0's RX FIFO (Core1→Core0) and wake it.
+ if (_fifo10.Count < FIFO_DEPTH)
+ _fifo10.Enqueue(0);
+ _cpu.SetInterrupt(15, true); // SIO_IRQ_PROC0: data available for Core 0
+ _cpu.Registers.EventRegistered = true; // SEV: wake Core 0 out of WFE
+ }
+
// ── FIFO helpers ──────────────────────────────────────────────────
private uint BuildFifoStatus(int coreId)
diff --git a/tests/RP2040Sharp.IntegrationTests/Firmware/python/micropython-v1.28.0.uf2 b/tests/RP2040Sharp.IntegrationTests/Firmware/python/micropython-v1.28.0.uf2
new file mode 100644
index 0000000..86ec9fc
Binary files /dev/null and b/tests/RP2040Sharp.IntegrationTests/Firmware/python/micropython-v1.28.0.uf2 differ
diff --git a/tests/RP2040Sharp.IntegrationTests/Tests/MicroPythonThreadTests.cs b/tests/RP2040Sharp.IntegrationTests/Tests/MicroPythonThreadTests.cs
new file mode 100644
index 0000000..368f2f6
--- /dev/null
+++ b/tests/RP2040Sharp.IntegrationTests/Tests/MicroPythonThreadTests.cs
@@ -0,0 +1,66 @@
+using FluentAssertions;
+using RP2040Sharp.IntegrationTests.Infrastructure;
+
+namespace RP2040Sharp.IntegrationTests.Tests;
+
+///
+/// End-to-end tests for MicroPython's _thread module on the second core.
+///
+/// MicroPython runs multicore_reset_core1() (force PROC1 off then on via
+/// PSM.FRCE_OFF) before every _thread.start_new_thread, then blocks in
+/// multicore_fifo_pop_blocking() waiting for Core 1's bootrom "ready" word. Before
+/// the PSM-driven Core 1 reset was emulated, Core 0 slept in WFE forever and any _thread
+/// program hung — this is what crashes the Wokwi pedestrian-crossing sketch (project
+/// 469350452950129665) and hangs it under this emulator.
+///
+[Trait("Category", "Integration")]
+public sealed class MicroPythonThreadTests
+{
+ private static bool ShouldSkip =>
+ Environment.GetEnvironmentVariable("SKIP_INTEGRATION_TESTS") == "1";
+
+ // v1.28.0: this is where MicroPython's _thread path runs multicore_reset_core1() before
+ // launching Core 1 (older builds such as v1.21.0 do not, so they cannot exercise the bug).
+ private const string Version = "v1.28.0";
+
+ ///
+ /// A _thread.start_new_thread must return (not hang in multicore_reset_core1's
+ /// blocking FIFO pop) and the spawned thread must actually run on Core 1.
+ ///
+ [Fact]
+ public async Task Thread_StartNewThread_RunsOnCore1_WithoutHanging()
+ {
+ if (ShouldSkip) return;
+
+ await using var runner = await MicroPythonRunner.CreateAsync(Version);
+ if (runner is null) return;
+
+ runner.WaitForPrompt().Should().BeTrue("MicroPython must reach the REPL to write files");
+
+ // Mirror the Wokwi crosswalk: a worker thread runs alongside the main loop.
+ const string mainPy =
+ "import _thread, time\n" +
+ "def worker():\n" +
+ " for i in range(3):\n" +
+ " print('CORE1', i)\n" +
+ " time.sleep(0.05)\n" +
+ "_thread.start_new_thread(worker, ())\n" +
+ "time.sleep(1)\n" +
+ "print('MAIN-DONE')\n";
+
+ runner.WriteFile("main.py", mainPy)
+ .Should().BeTrue("WriteFile should succeed when the REPL is ready");
+
+ runner.SoftReset(timeoutMs: 30_000)
+ .Should().BeTrue("MicroPython must return to the REPL after main.py — not hang at start_new_thread");
+
+ var text = runner.UsbCdc.IsConnected ? runner.UsbCdc.Text : runner.Uart.Text;
+
+ // start_new_thread returned and the main thread ran to completion.
+ text.Should().Contain("MAIN-DONE",
+ "the main thread must continue past start_new_thread instead of blocking in WFE");
+ // Core 1 actually executed the spawned thread body.
+ text.Should().Contain("CORE1",
+ "the worker thread must run on Core 1");
+ }
+}
diff --git a/tests/RP2040Sharp.IntegrationTests/Tests/VersionMatrixTests.cs b/tests/RP2040Sharp.IntegrationTests/Tests/VersionMatrixTests.cs
index 3d065e2..5b5482d 100644
--- a/tests/RP2040Sharp.IntegrationTests/Tests/VersionMatrixTests.cs
+++ b/tests/RP2040Sharp.IntegrationTests/Tests/VersionMatrixTests.cs
@@ -23,6 +23,7 @@ public sealed class VersionMatrixTests
["v1.19.1"],
["v1.20.0"],
["v1.21.0"],
+ ["v1.28.0"],
];
[Theory]
diff --git a/tests/RP2040Sharp.Tests/Multicore/MulticoreResetTests.cs b/tests/RP2040Sharp.Tests/Multicore/MulticoreResetTests.cs
new file mode 100644
index 0000000..8d08eba
--- /dev/null
+++ b/tests/RP2040Sharp.Tests/Multicore/MulticoreResetTests.cs
@@ -0,0 +1,67 @@
+using RP2040.Peripherals.Tests.Fixtures;
+
+namespace RP2040.Peripherals.Tests.Multicore;
+
+///
+/// Verifies the emulator reproduces pico-sdk's multicore_reset_core1(), which forces
+/// PROC1 off then on through PSM.FRCE_OFF. On silicon this brings Core 1 out of reset;
+/// Core 1 re-runs its bootrom and pushes a "ready" word to Core 0, which is blocked in
+/// multicore_fifo_pop_blocking(). MicroPython's _thread module runs this before
+/// every start_new_thread, so without it a _thread firmware (e.g. the Wokwi
+/// pedestrian-crossing sketch) hangs with Core 0 asleep in WFE.
+///
+public sealed class MulticoreResetTests : MachineTestBase
+{
+ // PSM @ 0x40010000. hw_set_bits/hw_clear_bits target the atomic SET/CLR aliases.
+ private const uint PSM_FRCE_OFF = 0x40010004;
+ private const uint PSM_FRCE_OFF_SET = 0x40012004; // base + SET(0x2000) + FRCE_OFF(0x04)
+ private const uint PSM_FRCE_OFF_CLR = 0x40013004; // base + CLR(0x3000) + FRCE_OFF(0x04)
+ private const uint PROC1_BIT = 1u << 16;
+
+ // SIO @ 0xD0000000. FIFO_ST bit0 = VLD (Core 0's RX FIFO has data).
+ private const uint SIO_FIFO_ST = 0xD0000050;
+ private const uint SIO_FIFO_RD = 0xD0000058;
+ private const uint FIFO_ST_VLD = 1u;
+
+ [Fact]
+ public void MulticoreResetCore1_InjectsReadyWord_ForBlockedPop()
+ {
+ // Core 0's RX FIFO starts empty (nothing available to a blocking pop).
+ (Machine.Bus.ReadWord(SIO_FIFO_ST) & FIFO_ST_VLD).Should().Be(0u,
+ "Core 0's RX FIFO must be empty before the reset");
+
+ // multicore_reset_core1(): hw_set_bits(PROC1), spin on read-back, hw_clear_bits(PROC1).
+ Machine.Bus.WriteWord(PSM_FRCE_OFF_SET, PROC1_BIT);
+ (Machine.Bus.ReadWord(PSM_FRCE_OFF) & PROC1_BIT).Should().Be(PROC1_BIT,
+ "the PROC1 force-off bit must read back as set (the SDK spins on this)");
+ Machine.Bus.WriteWord(PSM_FRCE_OFF_CLR, PROC1_BIT);
+
+ // Releasing PROC1 must make Core 1's bootrom push its "ready" sentinel to Core 0.
+ (Machine.Bus.ReadWord(SIO_FIFO_ST) & FIFO_ST_VLD).Should().Be(FIFO_ST_VLD,
+ "Core 1 leaving reset must signal Core 0 via the FIFO");
+ Machine.Bus.ReadWord(SIO_FIFO_RD).Should().Be(0u,
+ "the ready sentinel Core 1 pushes is 0");
+ }
+
+ [Fact]
+ public void MulticoreResetCore1_WakesCore0FromWfe()
+ {
+ Machine.Cpu.Registers.EventRegistered = false;
+
+ Machine.Bus.WriteWord(PSM_FRCE_OFF_SET, PROC1_BIT);
+ Machine.Bus.WriteWord(PSM_FRCE_OFF_CLR, PROC1_BIT);
+
+ Machine.Cpu.Registers.EventRegistered.Should().BeTrue(
+ "the reset must SEV Core 0 so a WFE inside multicore_fifo_pop_blocking wakes");
+ }
+
+ [Fact]
+ public void SettingProc1_WithoutRelease_DoesNotSignalCore0()
+ {
+ // Only the set → clear transition releases Core 1; setting alone must be inert.
+ Machine.Bus.WriteWord(PSM_FRCE_OFF_SET, PROC1_BIT);
+
+ (Machine.Bus.ReadWord(SIO_FIFO_ST) & FIFO_ST_VLD).Should().Be(0u,
+ "forcing PROC1 off must not push anything to Core 0");
+ }
+}