circuitpython/docs/reference/isr_rules.rst
Jim Mussared 9092909bf5 docs: Rename uasyncio to asyncio.
This work was funded through GitHub Sponsors.

Signed-off-by: Jim Mussared <jim.mussared@gmail.com>
2023-06-19 18:36:54 +10:00

415 lines
22 KiB
ReStructuredText

.. _isr_rules:
Writing interrupt handlers
==========================
On suitable hardware MicroPython offers the ability to write interrupt handlers in Python. Interrupt handlers
- also known as interrupt service routines (ISR's) - are defined as callback functions. These are executed
in response to an event such as a timer trigger or a voltage change on a pin. Such events can occur at any point
in the execution of the program code. This carries significant consequences, some specific to the MicroPython
language. Others are common to all systems capable of responding to real time events. This document covers
the language specific issues first, followed by a brief introduction to real time programming for those new to it.
This introduction uses vague terms like "slow" or "as fast as possible". This is deliberate, as speeds are
application dependent. Acceptable durations for an ISR are dependent on the rate at which interrupts occur,
the nature of the main program, and the presence of other concurrent events.
Tips and recommended practices
------------------------------
This summarises the points detailed below and lists the principal recommendations for interrupt handler code.
* Keep the code as short and simple as possible.
* Avoid memory allocation: no appending to lists or insertion into dictionaries, no floating point.
* Consider using ``micropython.schedule`` to work around the above constraint.
* Where an ISR returns multiple bytes use a pre-allocated ``bytearray``. If multiple integers are to be
shared between an ISR and the main program consider an array (``array.array``).
* Where data is shared between the main program and an ISR, consider disabling interrupts prior to accessing
the data in the main program and re-enabling them immediately afterwards (see Critical Sections).
* Allocate an emergency exception buffer (see below).
MicroPython issues
------------------
The emergency exception buffer
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If an error occurs in an ISR, MicroPython is unable to produce an error report unless a special buffer is created
for the purpose. Debugging is simplified if the following code is included in any program using interrupts.
.. code:: python
import micropython
micropython.alloc_emergency_exception_buf(100)
The emergency exception buffer can only hold one exception stack trace. This means that if a second exception is
thrown during the handling of an exception while the heap is locked, that second exception's stack trace will
replace the original one - even if the second exception is cleanly handled. This can lead to confusing exception
messages if the buffer is later printed.
Simplicity
~~~~~~~~~~
For a variety of reasons it is important to keep ISR code as short and simple as possible. It should do only what
has to be done immediately after the event which caused it: operations which can be deferred should be delegated
to the main program loop. Typically an ISR will deal with the hardware device which caused the interrupt, making
it ready for the next interrupt to occur. It will communicate with the main loop by updating shared data to indicate
that the interrupt has occurred, and it will return. An ISR should return control to the main loop as quickly
as possible. This is not a specific MicroPython issue so is covered in more detail :ref:`below <ISR>`.
Communication between an ISR and the main program
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Normally an ISR needs to communicate with the main program. The simplest means of doing this is via one or more
shared data objects, either declared as global or shared via a class (see below). There are various restrictions
and hazards around doing this, which are covered in more detail below. Integers, ``bytes`` and ``bytearray`` objects
are commonly used for this purpose along with arrays (from the array module) which can store various data types.
The use of object methods as callbacks
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
MicroPython supports this powerful technique which enables an ISR to share instance variables with the underlying
code. It also enables a class implementing a device driver to support multiple device instances. The following
example causes two LED's to flash at different rates.
.. code:: python
import pyb, micropython
micropython.alloc_emergency_exception_buf(100)
class Foo(object):
def __init__(self, timer, led):
self.led = led
timer.callback(self.cb)
def cb(self, tim):
self.led.toggle()
red = Foo(pyb.Timer(4, freq=1), pyb.LED(1))
green = Foo(pyb.Timer(2, freq=0.8), pyb.LED(2))
In this example the ``red`` instance associates timer 4 with LED 1: when a timer 4 interrupt occurs ``red.cb()``
is called causing LED 1 to change state. The ``green`` instance operates similarly: a timer 2 interrupt
results in the execution of ``green.cb()`` and toggles LED 2. The use of instance methods confers two
benefits. Firstly a single class enables code to be shared between multiple hardware instances. Secondly, as
a bound method the callback function's first argument is ``self``. This enables the callback to access instance
data and to save state between successive calls. For example, if the class above had a variable ``self.count``
set to zero in the constructor, ``cb()`` could increment the counter. The ``red`` and ``green`` instances would
then maintain independent counts of the number of times each LED had changed state.
Creation of Python objects
~~~~~~~~~~~~~~~~~~~~~~~~~~
ISR's cannot create instances of Python objects. This is because MicroPython needs to allocate memory for the
object from a store of free memory block called the heap. This is not permitted in an interrupt handler because
heap allocation is not re-entrant. In other words the interrupt might occur when the main program is part way
through performing an allocation - to maintain the integrity of the heap the interpreter disallows memory
allocations in ISR code.
A consequence of this is that ISR's can't use floating point arithmetic; this is because floats are Python objects. Similarly
an ISR can't append an item to a list. In practice it can be hard to determine exactly which code constructs will
attempt to perform memory allocation and provoke an error message: another reason for keeping ISR code short and simple.
One way to avoid this issue is for the ISR to use pre-allocated buffers. For example a class constructor
creates a ``bytearray`` instance and a boolean flag. The ISR method assigns data to locations in the buffer and sets
the flag. The memory allocation occurs in the main program code when the object is instantiated rather than in the ISR.
The MicroPython library I/O methods usually provide an option to use a pre-allocated buffer. For
example ``pyb.i2c.recv()`` can accept a mutable buffer as its first argument: this enables its use in an ISR.
A means of creating an object without employing a class or globals is as follows:
.. code:: python
def set_volume(t, buf=bytearray(3)):
buf[0] = 0xa5
buf[1] = t >> 4
buf[2] = 0x5a
return buf
The compiler instantiates the default ``buf`` argument when the function is
loaded for the first time (usually when the module it's in is imported).
An instance of object creation occurs when a reference to a bound method is
created. This means that an ISR cannot pass a bound method to a function. One
solution is to create a reference to the bound method in the class constructor
and to pass that reference in the ISR. For example:
.. code:: python
class Foo():
def __init__(self):
self.bar_ref = self.bar # Allocation occurs here
self.x = 0.1
tim = pyb.Timer(4)
tim.init(freq=2)
tim.callback(self.cb)
def bar(self, _):
self.x *= 1.2
print(self.x)
def cb(self, t):
# Passing self.bar would cause allocation.
micropython.schedule(self.bar_ref, 0)
Other techniques are to define and instantiate the method in the constructor
or to pass :meth:`Foo.bar` with the argument *self*.
Use of Python objects
~~~~~~~~~~~~~~~~~~~~~
A further restriction on objects arises because of the way Python works. When an ``import`` statement is executed the
Python code is compiled to bytecode, with one line of code typically mapping to multiple bytecodes. When the code
runs the interpreter reads each bytecode and executes it as a series of machine code instructions. Given that an
interrupt can occur at any time between machine code instructions, the original line of Python code may be only
partially executed. Consequently a Python object such as a set, list or dictionary modified in the main loop
may lack internal consistency at the moment the interrupt occurs.
A typical outcome is as follows. On rare occasions the ISR will run at the precise moment in time when the object
is partially updated. When the ISR tries to read the object, a crash results. Because such problems typically occur
on rare, random occasions they can be hard to diagnose. There are ways to circumvent this issue, described in
:ref:`Critical Sections <Critical>` below.
It is important to be clear about what constitutes the modification of an object. An alteration to a built-in type
such as a dictionary is problematic. Altering the contents of an array or bytearray is not. This is because bytes
or words are written as a single machine code instruction which is not interruptible: in the parlance of real time
programming the write is atomic. A user defined object might instantiate an integer, array or bytearray. It is valid
for both the main loop and the ISR to alter the contents of these.
MicroPython supports integers of arbitrary precision. Values between 2**30 -1 and -2**30 will be stored in
a single machine word. Larger values are stored as Python objects. Consequently changes to long integers cannot
be considered atomic. The use of long integers in ISR's is unsafe because memory allocation may be
attempted as the variable's value changes.
Overcoming the float limitation
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In general it is best to avoid using floats in ISR code: hardware devices normally handle integers and conversion
to floats is normally done in the main loop. However there are a few DSP algorithms which require floating point.
On platforms with hardware floating point (such as the Pyboard) the inline ARM Thumb assembler can be used to work
round this limitation. This is because the processor stores float values in a machine word; values can be shared
between the ISR and main program code via an array of floats.
Using micropython.schedule
~~~~~~~~~~~~~~~~~~~~~~~~~~
This function enables an ISR to schedule a callback for execution "very soon". The callback is queued for
execution which will take place at a time when the heap is not locked. Hence it can create Python objects
and use floats. The callback is also guaranteed to run at a time when the main program has completed any
update of Python objects, so the callback will not encounter partially updated objects.
Typical usage is to handle sensor hardware. The ISR acquires data from the hardware and enables it to
issue a further interrupt. It then schedules a callback to process the data.
Scheduled callbacks should comply with the principles of interrupt handler design outlined below. This is to
avoid problems resulting from I/O activity and the modification of shared data which can arise in any code
which pre-empts the main program loop.
Execution time needs to be considered in relation to the frequency with which interrupts can occur. If an
interrupt occurs while the previous callback is executing, a further instance of the callback will be queued
for execution; this will run after the current instance has completed. A sustained high interrupt repetition
rate therefore carries a risk of unconstrained queue growth and eventual failure with a ``RuntimeError``.
If the callback to be passed to `schedule()` is a bound method, consider the
note in "Creation of Python objects".
Exceptions
----------
If an ISR raises an exception it will not propagate to the main loop. The interrupt will be disabled unless the
exception is handled by the ISR code.
Interfacing to asyncio
----------------------
When an ISR runs it can preempt the `asyncio` scheduler. If the ISR performs a `asyncio`
operation the scheduler's operation can be disrupted. This applies whether the interrupt is hard
or soft and also applies if the ISR has passed execution to another function via
`micropython.schedule`. In particular creating or cancelling tasks is invalid in an ISR context.
The safe way to interact with `asyncio` is to implement a coroutine with synchronisation performed by
`asyncio.ThreadSafeFlag`. The following fragment illustrates the creation of a task in response
to an interrupt:
.. code:: python
tsf = asyncio.ThreadSafeFlag()
def isr(_): # Interrupt handler
tsf.set()
async def foo():
while True:
await tsf.wait()
asyncio.create_task(bar())
In this example there will be a variable amount of latency between the execution of the ISR and the execution
of ``foo()``. This is inherent to cooperative scheduling. The maximum latency is application
and platform dependent but may typically be measured in tens of ms.
General issues
--------------
This is merely a brief introduction to the subject of real time programming. Beginners should note
that design errors in real time programs can lead to faults which are particularly hard to diagnose. This is because
they can occur rarely and at intervals which are essentially random. It is crucial to get the initial design right and
to anticipate issues before they arise. Both interrupt handlers and the main program need to be designed
with an appreciation of the following issues.
.. _ISR:
Interrupt handler design
~~~~~~~~~~~~~~~~~~~~~~~~
As mentioned above, ISR's should be designed to be as simple as possible. They should always return in a short,
predictable period of time. This is important because when the ISR is running, the main loop is not: inevitably
the main loop experiences pauses in its execution at random points in the code. Such pauses can be a source of hard
to diagnose bugs particularly if their duration is long or variable. In order to understand the implications of
ISR run time, a basic grasp of interrupt priorities is required.
Interrupts are organised according to a priority scheme. ISR code may itself be interrupted by a higher priority
interrupt. This has implications if the two interrupts share data (see Critical Sections below). If such an interrupt
occurs it interposes a delay into the ISR code. If a lower priority interrupt occurs while the ISR is running, it
will be delayed until the ISR is complete: if the delay is too long, the lower priority interrupt may fail. A
further issue with slow ISR's is the case where a second interrupt of the same type occurs during its execution.
The second interrupt will be handled on termination of the first. However if the rate of incoming interrupts
consistently exceeds the capacity of the ISR to service them the outcome will not be a happy one.
Consequently looping constructs should be avoided or minimised. I/O to devices other than to the interrupting device
should normally be avoided: I/O such as disk access, ``print`` statements and UART access is relatively slow, and
its duration may vary. A further issue here is that filesystem functions are not reentrant: using filesystem I/O
in an ISR and the main program would be hazardous. Crucially ISR code should not wait on an event. I/O is acceptable
if the code can be guaranteed to return in a predictable period, for example toggling a pin or LED. Accessing the
interrupting device via I2C or SPI may be necessary but the time taken for such accesses should be calculated or
measured and its impact on the application assessed.
There is usually a need to share data between the ISR and the main loop. This may be done either through global
variables or via class or instance variables. Variables are typically integer or boolean types, or integer or byte
arrays (a pre-allocated integer array offers faster access than a list). Where multiple values are modified by
the ISR it is necessary to consider the case where the interrupt occurs at a time when the main program has
accessed some, but not all, of the values. This can lead to inconsistencies.
Consider the following design. An ISR stores incoming data in a bytearray, then adds the number of bytes
received to an integer representing total bytes ready for processing. The main program reads the number of bytes,
processes the bytes, then clears down the number of bytes ready. This will work until an interrupt occurs just
after the main program has read the number of bytes. The ISR puts the added data into the buffer and updates
the number received, but the main program has already read the number, so processes the data originally received.
The newly arrived bytes are lost.
There are various ways of avoiding this hazard, the simplest being to use a circular buffer. If it is not possible
to use a structure with inherent thread safety other ways are described below.
Reentrancy
~~~~~~~~~~
A potential hazard may occur if a function or method is shared between the main program and one or more ISR's or
between multiple ISR's. The issue here is that the function may itself be interrupted and a further instance of
that function run. If this is to occur, the function must be designed to be reentrant. How this is done is an
advanced topic beyond the scope of this tutorial.
.. _Critical:
Critical sections
~~~~~~~~~~~~~~~~~
An example of a critical section of code is one which accesses more than one variable which can be affected by an ISR. If
the interrupt happens to occur between accesses to the individual variables, their values will be inconsistent. This is
an instance of a hazard known as a race condition: the ISR and the main program loop race to alter the variables. To
avoid inconsistency a means must be employed to ensure that the ISR does not alter the values for the duration of
the critical section. One way to achieve this is to issue ``pyb.disable_irq()`` before the start of the section, and
``pyb.enable_irq()`` at the end. Here is an example of this approach:
.. code:: python
import pyb, micropython, array
micropython.alloc_emergency_exception_buf(100)
class BoundsException(Exception):
pass
ARRAYSIZE = const(20)
index = 0
data = array.array('i', 0 for x in range(ARRAYSIZE))
def callback1(t):
global data, index
for x in range(5):
data[index] = pyb.rng() # simulate input
index += 1
if index >= ARRAYSIZE:
raise BoundsException('Array bounds exceeded')
tim4 = pyb.Timer(4, freq=100, callback=callback1)
for loop in range(1000):
if index > 0:
irq_state = pyb.disable_irq() # Start of critical section
for x in range(index):
print(data[x])
index = 0
pyb.enable_irq(irq_state) # End of critical section
print('loop {}'.format(loop))
pyb.delay(1)
tim4.callback(None)
A critical section can comprise a single line of code and a single variable. Consider the following code fragment.
.. code:: python
count = 0
def cb(): # An interrupt callback
count +=1
def main():
# Code to set up the interrupt callback omitted
while True:
count += 1
This example illustrates a subtle source of bugs. The line ``count += 1`` in the main loop carries a specific race
condition hazard known as a read-modify-write. This is a classic cause of bugs in real time systems. In the main loop
MicroPython reads the value of ``count``, adds 1 to it, and writes it back. On rare occasions the interrupt occurs
after the read and before the write. The interrupt modifies ``count`` but its change is overwritten by the main
loop when the ISR returns. In a real system this could lead to rare, unpredictable failures.
As mentioned above, care should be taken if an instance of a Python built in type is modified in the main code and
that instance is accessed in an ISR. The code performing the modification should be regarded as a critical
section to ensure that the instance is in a valid state when the ISR runs.
Particular care needs to be taken if a dataset is shared between different ISR's. The hazard here is that the higher
priority interrupt may occur when the lower priority one has partially updated the shared data. Dealing with this
situation is an advanced topic beyond the scope of this introduction other than to note that mutex objects described
below can sometimes be used.
Disabling interrupts for the duration of a critical section is the usual and simplest way to proceed, but it disables
all interrupts rather than merely the one with the potential to cause problems. It is generally undesirable to disable
an interrupt for long. In the case of timer interrupts it introduces variability to the time when a callback occurs.
In the case of device interrupts, it can lead to the device being serviced too late with possible loss of data or
overrun errors in the device hardware. Like ISR's, a critical section in the main code should have a short, predictable
duration.
An approach to dealing with critical sections which radically reduces the time for which interrupts are disabled is to
use an object termed a mutex (name derived from the notion of mutual exclusion). The main program locks the mutex
before running the critical section and unlocks it at the end. The ISR tests whether the mutex is locked. If it is,
it avoids the critical section and returns. The design challenge is defining what the ISR should do in the event
that access to the critical variables is denied. A simple example of a mutex may be found
`here <https://github.com/peterhinch/micropython-samples.git>`_. Note that the mutex code does disable interrupts,
but only for the duration of eight machine instructions: the benefit of this approach is that other interrupts are
virtually unaffected.
Interrupts and the REPL
~~~~~~~~~~~~~~~~~~~~~~~
Interrupt handlers, such as those associated with timers, can continue to run
after a program terminates. This may produce unexpected results where you might
have expected the object raising the callback to have gone out of scope. For
example on the Pyboard:
.. code:: python
def bar():
foo = pyb.Timer(2, freq=4, callback=lambda t: print('.', end=''))
bar()
This continues to run until the timer is explicitly disabled or the board is
reset with ``ctrl D``.