- Defect
- Five in the RP2040 PIO driver. The one that matters delivers a block IRQ flag to the wrong state machine's client, and a flag whose state machine has no client is never cleared — so the peripheral keeps asserting and the kernel spins.
- Fix
- The client moves to the block, where the flags are, and the driver clears before dispatching. Five commits, +502 −263, four files.
- Status
- demonstrated on silicon Both the misrouting and the fix were run on a Pico 2 W over a Debug Probe — Section 3. No RP2040 board here, so nothing ran on the chip the driver is named for. Proposed as #5157; the first attempt, #5150, was closed for its description rather than its contents.
1The five defects
None of them reaches an in-tree caller today, which is why the driver has carried them. Each was found by writing a test that failed against unmodified upstream.
What each one is, and why nothing hit it
| Defect | Mechanism | Why no caller reaches it |
|---|---|---|
| RX FIFO join never happened | PioFifoJoinRx is 2 and FJOIN_RX is one
bit wide, so .val(2) masked to 0. PioFifoJoinNone
did nothing either, so a join could not be undone. |
No in-tree caller sets a FIFO join. |
add_program panicked above 16 instructions |
The buffer was [u16; NUMBER_INSTR_MEMORY_LOCATIONS / 2].
That constant is 32 and counts instructions, so the division
left 16 slots for 32. |
The only caller passes [u8; 14], and it is never
reached — see below. |
add_program panicked on odd byte counts |
Two bytes make one instruction; an odd length ran off the end.
Now ProgramError::NotInstructionAligned. |
Same caller, an even 14. |
| Three of four interrupt lines never dispatched | chip.rs handled PIO0_IRQ_0 and nothing
else. service_pending_interrupts panics on an
interrupt nobody claims. |
push enables irq_lines[Irq0] only, and
no board constructs PIO1. |
| Block IRQ flags went to the wrong client | handle_interrupt zipped the four state machines
against INTE/INTS bits 8–11, which are IRQ flags 0–3 and
belong to the block. |
The sole user, the Pico W gSPI radio path, uses SM0 and flag 0 — the diagonal, where wrong and right agree. |
Counted rather than asserted: upstream
chips/rp2040/src/chip.rs at 2a7a40450 matches
exactly one of the four PIO interrupt numbers. The only
add_program caller is pio_pwm.rs, whose program is
a literal [u8; 14], and PioPwm is constructed at
pico_explorer_base/src/main.rs:739 and never started.
tock/tock 2a7a40450.2Why the fifth one can hang the kernel
A PIO block has eight IRQ flags of its own. Any state machine can set any of
them, and the datasheet says so. What it also does is name the enable bits for
the first four SM0–SM3, sitting directly beside
SMn_TXNFULL and SMn_RXNEMPTY, which genuinely
are per state machine.
The driver read them correctly in one place and incorrectly in another. It
mapped InterruptSources::Interrupt0 to IRQ_INTE::SM0
and cleared it with interrupt_clear(0) — flag semantics. Then
handle_interrupt walked the four state machines alongside those
same four bits and called state machine n's client for flag n.
The hang needs no misbehaving client. If the flag's state machine has no registered client, nothing clears the flag. The peripheral goes on asserting, the pending bit stays latched, and the kernel's interrupt loop finds work forever without making progress.
Two smaller things were wrong in the same area. interrupt_clear
used modify() on a write-one-to-clear register, which reads
the other seven flags and writes them back — clearing flags nobody asked
about. And the six interrupt registers were six bitfield blocks of twelve
fields where three would do; collapsing them to
irq_lines: [IrqReg; 2] indexed by a two-variant enum made a whole
class of mistake unrepresentable rather than merely tested.
3On silicon
Both halves were run on a Pico 2 W over a Debug Probe, against
the RP2350 port of the same driver. A two-instruction PIO program —
irq 0, then a jump to itself, so the flag is raised exactly once
— runs on SM1, with a client registered on both SM0 and SM1
under distinct names. The console says which one the driver actually called.
The same board, the same program, the driver before and after
| Driver | Console | Outcome |
|---|---|---|
| unfixed | [defect5] SM1 is the state machine raising irq 0[defect5] the client on SM0 was called |
wrong client |
| fixed | [fixed] SM1 is the state machine raising irq 0[fixed] the block client was called, flags=0b0001 |
flag mask, no client to pick |
The state machine that raised the flag never heard about it, and the one that did not, did. After the fix there is no wrong client to choose, because state machines are not part of the dispatch at all — the client is handed the mask.
learning/bench/. The fix is PR #5157's change
ported onto the RP2350's copy of the driver, which is what made it runnable
here at all.The second property is the one worth having. In the fixed run the demo client clears nothing, and the board carried on and answered the process console afterwards. Under the unfixed driver an identically careless client produced two characters of output and stopped. The driver clearing before it dispatches is what makes a careless client harmless.
That hang was caught in the act, halted over SWD:
PC kernel/src/kernel.rs (the main loop) PIO0 irq (0x50200030) 0x00000001 flag 0 set, never cleared PIO0 INTR (0x5020016c) 0x000001f0 idle TXNFULL bits, plus bit 8 PIO0 INTS0(0x50200178) 0x00000100 asserted through to the NVIC NVIC ISPR (0xe000e200) 0x02008000 bit 15 = PIO0_IRQ_0 pending NVIC ISER (0xe000e100) 0x00000001 and NOT NVIC-enabled
The last pair is the mechanism: the pending bit latches whether or not the interrupt is enabled, so the kernel's scan finds it and spins. That capture is from 2026-09-04 and was not repeated in the 2026-09-08 re-run, which covered the misrouting on both arms.
4The tests, and what they cannot see
Nineteen host tests, in a #[cfg(test)] module that is not in any
shipped image. They run under ci-job-chips and need no new
dependency and no hardware. They pass under Miri with strict provenance and
tree borrows.
The tests were then reviewed by corrupting the file deliberately and checking that something failed. That found two tests which were passing while proving nothing, and both are more interesting than the count:
- A wrap test that masked away the wrap. It asserted on
wrapped[0] & 0x1f— the low five bits — while the behaviour under test was that a jump target wraps within those five bits. Deleting the% 32from the driver still passed. It now compares the whole instruction. - A map test that checked one of eight. The FIFO address test read a single block/state-machine pair, so "ignore which PIO block" survived untouched. It now loops both blocks and all four state machines.
Both descriptions are of versions that were fixed before these commits were made, so neither is in the history and a reader cannot check them against the repository. What is checkable is the shape the tests have now.
What survives the corruption is the honest part: the register writes. A host test can call a function and inspect what it returns, and it cannot observe a write to a peripheral that is not there. Every surviving mutation is one of those. That is the ceiling on this kind of testing, and it is the reason #5152 exists.
5It is smaller, and the arithmetic says why
All four RP2040 boards, built from both trees
| Board | text before | text after | bss before | bss after |
|---|---|---|---|---|
| raspberry_pi_pico | 98348 | 98348 | 16528 | 16480 |
| raspberry_pi_pico_w | 352300 | 352300 | 20724 | 20676 |
| pico_explorer_base | 102444 | 102444 | 72668 | 72620 |
| nano_rp2040_connect | 94252 | 94252 | 14368 | 14320 |
Text identical on every board; bss down 48 bytes on every board. The arithmetic matches the change exactly: eight fat pointers removed and two added, across two PIO blocks, at 8 bytes each on a 32-bit target.
6A merge hazard worth knowing about
#5126 deletes
mod examples, 273 lines, from the end of pio.rs. This
change adds #[cfg(test)] mod tests, 262 lines, to the end of the
same file. To git that is one region against another, and it cannot tell them
apart.
Taking the deletion drops all nineteen tests, and everything still looks
fine. The crate builds, clippy is clean, all four boards build at their
expected sizes. The only thing that says otherwise is cargo test
reporting 0 passed. After resolving a conflict, run the tests
rather than the build — a build proves nothing about a test module
that is no longer there.
7What this does not establish
- Nothing ran on an RP2040. The silicon work is all RP2350, on the port of the same driver. The defects are in the RP2040 driver and the two chips share the design, but that is an argument, not a measurement.
- The gSPI path was not exercised. It is the only in-tree PIO user, and its client registration is exactly what moved — from per state machine to per block. Someone with a Pico W running the radio would close the most important remaining gap.
- Host tests cannot see a register write. Every mutation that survived review is one, and no amount of host testing changes that.
- The hang capture was not repeated. The misrouting A/B was re-run on 2026-09-08; the halted-core register readout above is from 2026-09-04.
- The two tests that proved nothing cannot be inspected. Both were corrected before the commits were written, so the versions described in Section 4 exist in no revision of this branch. What they became is in the diff; what they were is a claim about the work.
- The mutation review is not reproducible as a number. The harness was not kept, and two records of the totals disagree by one. What it found is written down; the count is not evidence anyone can check.