Interrupts
The Big Idea
Normally, a CPU runs one instruction after another — sequential execution. But sometimes, external events need immediate attention:
- A pin changes state (button press, sensor signal)
- A timer expires
- Data arrives via UART
Polling means checking for these events in a loop — the CPU repeatedly asks "did anything happen?" This works, but wastes cycles and adds latency (the event waits until the next check).
Interrupts let the hardware notify the CPU when something happens. The CPU pauses what it's doing, handles the event, then resumes. No wasted cycles, near-instant response.
What Happens When an Interrupt Fires
- The CPU pauses the current instruction stream
- It saves its state (registers, program counter) onto the stack
- It jumps to the ISR (Interrupt Service Routine) — a function you wrote
- After the ISR returns, it restores state and resumes where it left off
Think of it like someone tapping you on the shoulder while you're writing. You mark your place, handle what they need, then come back and resume.
Polling vs Interrupts — When to Use Each
| Polling | Interrupts | |
|---|---|---|
| How it works | Main loop checks a flag/pin periodically | Hardware triggers your handler instantly |
| Latency | Up to one full loop iteration | Near-instant (µs) |
| CPU usage | Wastes cycles checking | Zero cost when idle |
| Code complexity | Simple | More care needed (ISR rules) |
| Best for | Slow-changing values (temperature, battery) | Edges, pulses, time-critical events |
Rule of thumb: Start with polling. Switch to interrupts when you can measure that polling adds unacceptable jitter or latency. You experienced this progression in Timing Fundamentals (polling) → State Machines Part 7 (interrupts).
MicroPython Interrupts on RP2350
Pin Interrupts — Pin.irq()
The most common interrupt source: a GPIO pin changes state.
from machine import Pin
def button_handler(pin):
"""Called when button state changes."""
# ISR — keep it short!
global button_pressed
button_pressed = True
button = Pin(16, Pin.IN, Pin.PULL_UP)
button.irq(trigger=Pin.IRQ_FALLING, handler=button_handler)
Trigger modes:
| Mode | Fires when | Use case |
|---|---|---|
Pin.IRQ_FALLING |
HIGH → LOW | Button press (active-low) |
Pin.IRQ_RISING |
LOW → HIGH | Sensor signal start |
Pin.IRQ_FALLING \| Pin.IRQ_RISING |
Any edge | Pulse timing (both edges) |
Removing an IRQ:
Hardware Timers — machine.Timer
The RP2350 has hardware timers that can fire ISRs at precise intervals, independent of your main loop.
from machine import Timer
def tick(timer):
"""Called every 100ms by hardware."""
global heartbeat
heartbeat = not heartbeat
timer = Timer(-1) # -1 = allocate any available timer
timer.init(period=100, mode=Timer.PERIODIC, callback=tick)
# Later: clean up
timer.deinit()
| Parameter | Options | Notes |
|---|---|---|
period |
Milliseconds | Minimum ~1 ms in MicroPython |
mode |
Timer.PERIODIC / Timer.ONE_SHOT |
Repeating or single fire |
callback |
Function taking timer arg |
Runs as ISR — same rules apply |
Disabling Interrupts — Critical Sections
When reading/writing shared variables that are wider than one machine word, interrupts can fire between operations, corrupting data:
import machine
# Disable all interrupts (returns previous state)
state = machine.disable_irq()
# Critical section — no interrupts can fire here
shared_value = new_value
# Re-enable interrupts
machine.enable_irq(state)
Keep Critical Sections Short
While interrupts are disabled, all hardware events queue up. If you disable for too long, you'll miss pulses, drop UART bytes, or cause timer drift. A few microseconds is fine; a millisecond is too long.
ISR Rules — What You Can and Can't Do
ISRs run inside the hardware interrupt context, which means the normal Python runtime is partially suspended. Breaking these rules causes crashes, hangs, or subtle corruption.
The Rules
| Rule | Why | What happens if you break it |
|---|---|---|
No print() |
UART is interrupt-driven itself | Deadlock or garbled output |
No time.sleep() |
Blocks the entire CPU including other ISRs | System hangs |
| No heap allocation | list.append(), string formatting, creating objects all allocate |
Crashes if GC is running |
| No I2C / SPI / UART | These use interrupts internally | Deadlock |
No micropython.schedule() nesting |
Can only queue one deferred call | Silently dropped |
| Keep it fast | Other interrupts are blocked while yours runs | Missed events, jitter |
The Safe Pattern: Flag + Main Loop
The standard approach is to do minimal work in the ISR (set a flag, capture a timestamp), then process in the main loop:
from machine import Pin
import time
# Shared state — written by ISR, read by main loop
_event_flag = False
_event_time = 0
def _pin_handler(pin):
"""ISR: capture timestamp, set flag, get out."""
global _event_flag, _event_time
_event_time = time.ticks_us()
_event_flag = True
sensor = Pin(15, Pin.IN)
sensor.irq(trigger=Pin.IRQ_FALLING, handler=_pin_handler)
# Main loop — process when flag is set
while True:
if _event_flag:
_event_flag = False
elapsed = time.ticks_diff(time.ticks_us(), _event_time)
print(f"Event! Latency: {elapsed} µs")
# ... other work ...
Practical Example: Ultrasonic Echo Timing
This is exactly the pattern you use in State Machines Part 7 — the echo pin fires on both edges, the ISR captures timestamps, and the main loop reads the result:
from machine import Pin
import time
class UltrasonicIRQ:
def __init__(self, trig_pin=14, echo_pin=15):
self._trig = Pin(trig_pin, Pin.OUT)
self._echo = Pin(echo_pin, Pin.IN)
self._trig.off()
self._rise_time = 0
self._distance = 100.0
self._measuring = False
# Both edges — rising = echo start, falling = echo end
self._echo.irq(
trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING,
handler=self._echo_handler
)
def _echo_handler(self, pin):
"""ISR: ~5 µs execution time."""
if pin.value() == 1:
self._rise_time = time.ticks_us() # Echo started
else:
pulse = time.ticks_diff(time.ticks_us(), self._rise_time)
if 100 < pulse < 25000:
self._distance = pulse / 58.0 # Convert to cm
self._measuring = False
@property
def distance(self):
"""Read last result — instant, never blocks."""
return self._distance
Why this works: The ISR only does ticks_us(), ticks_diff(), a comparison, and a division — all constant-time operations with no heap allocation. Total ISR time is ~5 µs, which is negligible.
Why polling couldn't: time_pulse_us() (the polling approach) spins in a busy-wait loop for up to 25 ms, blocking the CPU entirely.
Shared Variables and Race Conditions
When the main loop and an ISR both access the same variable, you have a potential race condition. In MicroPython on RP2350, simple integer and float assignments are atomic (single machine word), so:
# Safe — single-word writes are atomic on Cortex-M33
_distance = pulse / 58.0 # ISR writes
d = _distance # Main loop reads
But multi-step operations are NOT safe:
# UNSAFE — ISR could fire between these two lines
x = shared_low_byte
y = shared_high_byte # ISR might update low_byte here!
Solution: Use machine.disable_irq() / enable_irq() for multi-word reads, or design your ISR to write a single result variable.
MicroPython-Specific: micropython.schedule()
If your ISR needs to do "heavier" work (print, I2C, allocate), you can defer it:
import micropython
def deferred_handler(pin):
"""Runs in main thread context — full Python available."""
print(f"Pin {pin} changed!")
def isr(pin):
micropython.schedule(deferred_handler, pin)
pin.irq(handler=isr)
schedule() queues the function to run as soon as the main loop yields. It's safer but adds latency (~100-500 µs). Only one call can be queued at a time — a second schedule() before the first runs is silently dropped.
Interrupt Sources on RP2350
| Source | Trigger | MicroPython API | Typical use |
|---|---|---|---|
| GPIO pin | Edge or level | Pin.irq() |
Button, sensor pulse, echo timing |
| Hardware timer | Period or one-shot | Timer(callback=...) |
Periodic measurements, watchdog |
| UART RX | Data available | UART.irq() |
Serial communication |
| ADC | Conversion complete | Not exposed in MP | (Use C SDK) |
| DMA | Transfer complete | Not exposed in MP | (Use C SDK) |
| PIO | State machine event | StateMachine.irq() |
Custom protocols |
What's Not Available in MicroPython
The RP2350's NVIC supports configurable priority levels, nested interrupts, and tail-chaining. MicroPython exposes only a subset — you get basic pin/timer IRQs without priority control. For full NVIC access, you need the C SDK.
This is fine for learning and prototyping. When you need more, that's when you move to C — and you'll already understand the concepts.
How It Differs by Platform
| Feature | Arduino (ATmega328P) | RP2350 (Cortex-M33) | STM32 (Cortex-M4) |
|---|---|---|---|
| Core | 8-bit AVR | 2× 32-bit ARM | 32-bit ARM |
| Interrupt controller | Fixed priorities | NVIC (limited in MP) | NVIC (full access) |
| Nested interrupts | Manual, limited | Supported | Full support |
| How to write ISR | ISR(INT0_vect) |
pin.irq(handler=) |
HAL callbacks / NVIC |
| Timer interrupts | Timer1/Timer2 | machine.Timer |
TIM1-TIM14 |
| DMA integration | None | PIO + DMA | Full DMA channels |
Professional Context
Beyond MicroPython — How Industry Uses Interrupts
Your robot uses interrupts for a single ultrasonic sensor. Professional systems manage dozens of interrupt sources with strict timing guarantees:
### RTOS Pattern (FreeRTOS, Zephyr)
ISR: Encoder pulse
└── Immediate: increment counter, signal semaphore
Task: Motor Control (Priority HIGH, Period 1ms)
└── Waits on semaphore, reads counter, updates PWM
Task: Sensor Fusion (Priority MEDIUM, Period 10ms)
└── Combines IMU + encoders + ultrasonic
Task: Display (Priority LOW, Period 100ms)
└── Runs only when CPU is free
### Automotive (AUTOSAR)
| Category | Allowed operations | Use case |
|---|---|---|
| Cat 1 ISR | No OS calls, minimal | Fast hardware response |
| Cat 2 ISR | Limited OS services | Complex handlers |
| Runnable | Full OS, scheduled | Application logic |
All ISRs have proven WCET (Worst-Case Execution Time) — mathematical guarantees that deadlines are met. This is required for ISO 26262 (automotive safety).
### Priority Inversion — Mars Pathfinder (1997)
A classic bug where a low-priority task held a resource needed by a high-priority task, while a medium-priority task ran instead:
High priority ─────────┐ waiting for mutex
Medium priority ─────────┴──────────► runs instead!
Low priority ──────────┘ holds mutex, can't release
Result: High-priority task starved, watchdog reset the system. Fix: Priority inheritance — the low-priority task temporarily gets boosted so it can release the mutex faster.
Your MicroPython system is too simple for this (no RTOS, no mutexes), but understanding it matters when you move to real-time systems.
### What the Industry Uses
| System | Product | Domain |
|---|---|---|
| FreeRTOS | Amazon FreeRTOS | General embedded |
| Zephyr | Linux Foundation | IoT, wearables |
| QNX Neutrino | BlackBerry | Automotive, medical |
| VxWorks | Wind River | Aerospace, defense |
| AUTOSAR | Vector MICROSAR | Automotive ECUs |
Hardware Limits
What Software Can and Cannot Fix
Software CAN improve: - Response time → use interrupts instead of polling - Priority handling → implement simple priority scheme - ISR duration → keep ISRs short, defer work to main loop - Determinism (partial) → disable interrupts during critical sections
Software CANNOT fix: - Python bytecode overhead → inherent ~10-100× slower than C - Garbage collection pauses → can be milliseconds, unpredictable - Missed interrupts while GC runs → need bare metal or RTOS - Sub-microsecond timing → need hardware timer capture or PIO - Formal verification → needs specialized tools and language
The lesson: MicroPython interrupts are fine for echo timing, buttons, and learning the concepts. For guaranteed sub-millisecond response, you need C/C++ with an RTOS. For safety-critical systems, you need certified tools and formal verification.
When MicroPython Interrupts Hit Their Limits
| Requirement | MicroPython | C with RTOS | Bare Metal C |
|---|---|---|---|
| Ultrasonic echo timing | ✓ Works well | ✓ Guaranteed | ✓ Guaranteed |
| 1 kHz encoder | ✓ Usually works | ✓ Guaranteed | ✓ Guaranteed |
| 10 kHz encoder | ✗ Misses pulses | ✓ With DMA | ✓ Guaranteed |
| 100 kHz encoder | ✗ Impossible | ✗ Use hardware counter | ✓ With PIO |
| 1 MHz signal | ✗ Impossible | ✗ Need dedicated silicon | PIO or FPGA |
Further Reading
In this course:
- Timing Fundamentals — Non-blocking polling pattern (the step before interrupts)
- State Machines Part 7 — Hands-on: interrupt-driven ultrasonic
- Execution Models — Polling vs interrupts vs RTOS
- Architectures — System design patterns
External:
- MicroPython ISR Rules — Official guide to writing safe ISRs
- RP2350 Datasheet — Interrupts — NVIC and interrupt controller details
- FreeRTOS Documentation — RTOS concepts and API