A PIO interrupt flag delivered to the wrong client

tock/tock#5157 open when this was written five defects, found 2026-09-04

#5150 closed, superseded the first attempt, closed for its description

The RP2040 PIO block raises eight IRQ flags that belong to the block. The driver delivered flag n to state machine n's client — an association the hardware does not have, and which the datasheet invites by naming those bits SM0SM3. It has never misbehaved in the tree because the only user sits on the diagonal.

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

DefectMechanismWhy 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.

Figure 1. The five defects, and the reason each has survived. Read against 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 SM0SM3, 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

DriverConsoleOutcome
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.

Figure 2. Re-run 2026-09-08 on the same board, from the patches kept at 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:

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

Boardtext beforetext afterbss beforebss after
raspberry_pi_pico98348983481652816480
raspberry_pi_pico_w3523003523002072420676
pico_explorer_base1024441024447266872620
nano_rp2040_connect94252942521436814320

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.

Figure 3. Both trees built and compared on 2026-09-08. A number that matches a mechanism is worth more than a number that is merely smaller.

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