rp2: Crash with hard pin IRQ
Measuring hard IRQ latency with this script:
from machine import Pin, PWM
from time import sleep_ms
import math
# Link 16-17, time 16-18
pin18 = Pin(18, Pin.OUT)
def callback(_):
pin18(1) # Trigger
pin18(0)
pin17 = Pin(17, Pin.IN)
_ = pin17.irq(callback, trigger=Pin.IRQ_RISING, hard=True)
pwm = PWM(Pin(16))
pwm.freq(1000)
pwm.duty_u16(0xffff // 2)
while True:
y = 0
for x in range(100):
y += math.sin(x * 2 * math.pi/100) # 6.2ms of busywork
sleep_ms(10)
The usual latency was a creditable 25μs, but I have measured 100μs. It was not practicable to measure a worst case because the the machine suffers a hard crash usually almost immediately. If I comment out the busywork, leaving just the sleep_ms(10), it runs indefinitely with about 20μs latency.
rp2/machine_timer: Support hard IRQ timer callbacks.
Summary
When using Timer on the pyboard (and stm32 more generally), the callback is run in hard IRQ context and is reliably punctual.
On a Pi Pico 2 board, I was surprised to find that the rp2 port instead queues timer callbacks via mp_sched_schedule() which means they are subject to GC delays and potentially unbounded jitter.
This probably makes sense as an 'easy mode' default, allowing allocation and less disciplined code in a timer callback, but having it as the only option makes tight timing more awkward on rp2 than it is on stm32. Searching discussions, some have worked around the unpunctual timers by repurposing a PWM output with a hard trigger IRQ. A PIO state machine in an irq() loop with a hard IRQ handler would also do the job, but in each case we're wasting limited resources and adding complexity.
Rather than hacking around the problem once again, it feels sensible to add a (non-default) option to make the rp2 timers dispatch callbacks in hard IRQ context in the same way stm32 timers do.
Add a hard= parameter to the Timer() constructor and init, matching the hard= parameter on Pin.irq() and rp2.DMA.irq() and rp2.PIO.irq(). When set true, this dispatches the callback in hard IRQ context in the same way as the other hard IRQ handlers for rp2 and the timer callbacks for stm32, taking the same precautions to lock the scheduler and GC, catch unhandled exceptions, and disable the callback on unhandled exceptions.
Does this make sense? I've tested this reasonably heavily in my own application without uncovering any problems (see below for a demo) but would be grateful for any thoughts. For example, should this patch make the behaviour configurable in the other ports at the same time? Obviously, I'll update the patch to update the documentation to match wherever it ends up as well.
Testing
To demonstrate the GC jitter with the default soft callbacks on a Pi Pico or Pico 2, try:
from machine import Pin, Timer
pin = machine.Pin(0, machine.Pin.OUT)
timer = Timer(freq = 1000, mode = Timer.PERIODIC, callback = lambda t: pin.toggle())
x = 0.3
while True:
x = 4 * x * (1 - x)
with a scope on pin GP0. The fp loop allocates and gcs heavily, so you'll see the 500Hz square wave often lengthen out from 1 ms to 2-4 ms when the callback is delayed.
With this patch and the new hard callbacks, this effect disappears and the jitter is down in the single digit microseconds:
from machine import Pin, Timer
pin = machine.Pin(0, machine.Pin.OUT)
timer = Timer(freq = 1000, mode = Timer.PERIODIC, callback = lambda t: pin.toggle(), hard = True)
x = 0.3
while True:
x = 4 * x * (1 - x)
I've tested with multiple concurrent timers of disjoint frequencies up to 20kHz (pushing towards 100% utilisation in interrupt handling) on a variety of different RP2350 boards.