Skip to content

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

  1. The CPU pauses the current instruction stream
  2. It saves its state (registers, program counter) onto the stack
  3. It jumps to the ISR (Interrupt Service Routine) — a function you wrote
  4. 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:

pin.irq(handler=None)  # Detach interrupt

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:

External: