Skip to main content

MicroPython ISR Constraints: Handling 'MemoryError' and Heap Allocation Inside Interrupts

 You have wired up a simple push button to an ESP32 or a Raspberry Pi Pico. You attach an interrupt handler to detect the rising edge, and inside that handler, you attempt a simple operation: logging a message with a timestamp or appending a sensor reading to a list.

The moment the button is pressed, your microcontroller panics: MemoryError: memory allocation failed.

This is the most common pitfall in embedded Python development. It occurs because you are treating a MicroPython Interrupt Service Routine (ISR) like a standard Python function. It is not. This post dissects the architectural constraints of the MicroPython heap during interrupts and provides the canonical solution using micropython.schedule.

The Root Cause: Re-entrancy and the Locked Heap

To understand the error, you must understand the MicroPython memory model.

In standard CPython (desktop), "interrupts" are actually signal handlers handled by the main loop. In MicroPython running on bare metal, interrupts are real hardware interrupts. When a GPIO pin goes high, the CPU pauses the main execution thread, saves the register context, and jumps immediately to your ISR code.

The Conflict

MicroPython manages memory via a Garbage Collector (GC). The GC utilizes a linked list to track free memory blocks on the heap.

  1. The GC is not re-entrant. If the main loop is in the middle of allocating memory (manipulating the heap's linked list) and an interrupt fires, the heap is in an unstable state.
  2. The Lock. To prevent heap corruption, MicroPython locks the heap the moment an ISR begins.
  3. The Allocation Attempt. If your ISR code attempts to create an object, MicroPython tries to call malloc. Because the heap is locked, malloc fails immediately, raising a MemoryError.

What triggers allocation?

You might trigger allocation without realizing it. The following operations require heap memory and will fail inside an ISR:

  • Creating a string (e.g., print(f"Value: {x}")).
  • Creating a tuple, list, or dictionary.
  • Floating point arithmetic (in many ports, floats are boxed objects).
  • Allocating a new class instance.
  • Appending to a list (if the list needs to resize).

The Fix: Deferred Execution with micropython.schedule

You cannot force the heap to unlock inside an ISR. Instead, you must defer the logic that requires memory allocation to the main thread context.

While you could set a global flag variable and poll it in a while True loop, that introduces latency and creates messy "spaghetti code."

The correct approach is micropython.schedule(callback, arg).

This function places a pointer to a Python function and a single argument into a pre-allocated queue. When the ISR finishes and the VM resumes the main loop, it checks this queue and executes the callback outside the interrupt context. In this context, the heap is unlocked, and memory allocation is safe.

Implementation

Below is a robust, class-based implementation for an ESP32 or Raspberry Pi Pico. This code demonstrates how to handle a hardware interrupt, capture data, and safely process it using the schedule mechanism.

It also includes alloc_emergency_exception_buf, which is critical for debugging. Without it, if an exception occurs in an ISR, MicroPython cannot allocate memory to generate the traceback, leaving you with no error message.

import machine
import micropython
import time

# CRITICAL: Allocate memory for ISR stack traces. 
# Without this, you won't see why an ISR failed.
micropython.alloc_emergency_exception_buf(100)

class GpioHandler:
    def __init__(self, pin_number):
        self.pin = machine.Pin(pin_number, machine.Pin.IN, machine.Pin.PULL_DOWN)
        self.last_trigger = 0
        self.debounce_ms = 200
        
        # Configure the interrupt
        self.pin.irq(trigger=machine.Pin.IRQ_RISING, handler=self._isr_handler)
        print(f"Listening for interrupts on GPIO {pin_number}...")

    def _isr_handler(self, pin):
        """
        The Hard Interrupt Handler.
        RESTRICTIONS:
        1. No memory allocation (no creating strings, lists, floats).
        2. Keep it incredibly short.
        """
        # We can safely read time.ticks_ms() as it returns a small int (usually cached) 
        # or works without heap on most architectures.
        now = time.ticks_ms()
        
        # Basic Debounce Logic (integer math is safe)
        if time.ticks_diff(now, self.last_trigger) > self.debounce_ms:
            self.last_trigger = now
            
            # ATTEMPTING TO PRINT HERE WOULD CAUSE MEMORYERROR
            # print(f"IRQ on {pin}") <--- DON'T DO THIS
            
            # SOLUTION: Schedule the work.
            # We pass the 'pin' object or an ID as the argument.
            # Note: We wrap the call in a try/except because the schedule queue
            # has a limited depth (default 4).
            try:
                micropython.schedule(self._deferred_worker, pin)
            except RuntimeError:
                # This happens if the schedule queue is full.
                # In high-frequency interrupts, this prevents a crash.
                pass

    def _deferred_worker(self, pin_obj):
        """
        The Scheduled Task.
        CONTEXT: Main Thread (Soft context).
        CAPABILITIES: Full Python power (print, allocate, network calls).
        """
        # We can now safely create strings and allocate memory
        timestamp = time.localtime()
        formatted_time = "{:02}:{:02}:{:02}".format(timestamp[3], timestamp[4], timestamp[5])
        
        print(f"[{formatted_time}] Event detected on Pin: {pin_obj}")
        
        # Example of heavy allocation: Appending to a list or processing JSON
        # logic_data = {"pin": str(pin_obj), "epoch": time.time()}

# --- Usage Example ---

# GPIO 0 is usually the "Boot" button on ESP32/ESP8266
# GPIO 15 or 14 is often usable on Pico. Adjust for your board.
TARGET_PIN = 0 

def main():
    handler = GpioHandler(TARGET_PIN)
    
    # Simulate main loop activity
    counter = 0
    while True:
        # The scheduled task will "interrupt" this loop gracefully
        # between opcodes.
        print(f"Main loop running... {counter}")
        counter += 1
        time.sleep(2)

if __name__ == "__main__":
    main()

How It Works

  1. The Trigger: The user presses the button. The hardware asserts the interrupt.
  2. The ISR (_isr_handler):
    • The CPU halts the while True loop.
    • The ISR executes. It performs integer math (debouncing) which does not require the heap.
    • It calls micropython.schedule. This pushes a reference to _deferred_worker and the pin argument into a fixed-size queue inside the VM.
    • The ISR returns immediately.
  3. The VM Resumes: Control returns to the main thread.
  4. The Context Switch: Before executing the next bytecode of the main loop, the VM checks the schedule queue.
  5. The Worker (_deferred_worker):
    • The VM finds the pending job.
    • It executes _deferred_worker.
    • Because we are back in the main thread flow, the heap is unlocked. String formatting and printing execute successfully.
  6. Resume Main: Once the worker returns, the while True loop continues exactly where it left off.

Conclusion

MicroPython brings high-level language features to low-level hardware, but the abstraction leaks when dealing with interrupts. You cannot ignore the memory model.

By strictly separating your logic into Hardware Acknowledgement (ISR) and Data Processing (Scheduled Task), you ensure system stability and avoid the dreaded MemoryError. Always keep your ISRs empty of logic, and let micropython.schedule handle the heavy lifting.