编写中断处理程序

在合适的硬件上,MicroPython 提供了用 Python 编写中断处理程序的能力。中断处理程序——也称为中断服务程序 (ISR)——被定义为回调函数。这些是为了响应诸如定时器触发或引脚上的电压变化之类的事件而执行的。此类事件可能发生在程序代码执行过程中的任何一点。这会带来重大后果,其中一些是 MicroPython 语言特有的。其他的对于能够响应实时事件的所有系统都是通用的。本文档首先涵盖语言特定的问题,然后是对实时编程的新手的简要介绍。

本介绍使用了诸如“慢”或“尽可能快”之类的模糊术语。这是故意的,因为速度取决于应用程序。ISR 可接受的持续时间取决于中断发生的速率、主程序的性质以及其他并发事件的存在。

MicroPython 问题

紧急异常缓冲区

如果 ISR 中发生错误,MicroPython 无法生成错误报告,除非为此目的创建特殊缓冲区。如果以下代码包含在使用中断的任何程序中,调试将得到简化。

import micropython
micropython.alloc_emergency_exception_buf(100)

紧急异常缓冲区只能保存一个异常堆栈跟踪。这意味着,如果在堆被锁定的情况下处理异常期间抛出第二个异常,则第二个异常的堆栈跟踪将替换原来的堆栈跟踪 - 即使第二个异常得到了干净的处理。如果稍后打印缓冲区,这可能会导致混淆异常消息。

简单

出于各种原因,保持 ISR 代码尽可能短和简单很重要。它应该只做在导致它的事件之后必须立即做的事情:可以延迟的操作应该委托给主程序循环。通常,ISR 将处理引起中断的硬件设备,使其为下一个中断的发生做好准备。它将通过更新共享数据与主循环通信以指示中断已发生,然后返回。ISR 应尽快将控制权返回到主循环。这不是一个特定的 MicroPython 问题,因此在下面有更详细的介绍。

ISR 和主程序之间的通信

通常,ISR 需要与主程序进行通信。执行此操作的最简单方法是通过一个或多个共享数据对象,这些对象要么声明为全局对象,要么通过类共享(见下文)。这样做有各种限制和危险,下面将详细介绍。整数bytesbytearray 对象通常与可以存储各种数据类型的数组(来自数组模块)一起用于此目的。

使用对象方法作为回调

MicroPython 支持这种强大的技术,该技术使 ISR 能够与底层代码共享实例变量。它还使实现设备驱动程序的类能够支持多个设备实例。以下示例使两个 LED 以不同的速率闪烁。

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))

在此示例中, red实例将计时器 4 与 LED 1 相关联:当计时器 4 发生中断时,red.cb() 会调用导致 LED 1 更改状态。的 green实例类似地操作:一个计时器在执行2分中断的结果 green.cb() 和切换LED 2.使用实例方法赋予两个好处的。首先,单个类允许在多个硬件实例之间共享代码。其次,作为绑定方法,回调函数的第一个参数是self。这使回调能够访问实例数据并在连续调用之间保存状态。例如,如果上面的类self.count 在构造函数中将变量设置为零,则cb()可以增加计数器。在redgreen然后实例将维护每个 LED 改变状态的次数的独立计数。

创建 Python 对象

ISR 不能创建 Python 对象的实例。这是因为 MicroPython 需要从称为堆的空闲内存块存储中为对象分配内存。这在中断处理程序中是不允许的,因为堆分配是不可重入的。换句话说,当主程序部分执行分配时,可能会发生中断 - 为了保持堆的完整性,解释器不允许在 ISR 代码中分配内存。

这样做的结果是 ISR 不能使用浮点运算;这是因为浮点数是 Python 对象。同样,ISR 不能将项目附加到列表中。在实践中,很难准确确定哪些代码结构将尝试执行内存分配并引发错误消息:保持 ISR 代码简短和简单的另一个原因。

避免此问题的一种方法是让 ISR 使用预先分配的缓冲区。例如,类构造函数创建一个bytearray 实例和一个布尔标志。ISR 方法将数据分配到缓冲区中的位置并设置标志。当对象被实例化时,内存分配发生在主程序代码中,而不是在 ISR 中。

MicroPython 库 I/O 方法通常提供使用预分配缓冲区的选项。例如 pyb.i2c.recv()可以接受一个可变缓冲区作为它的第一个参数:这使得它可以在 ISR 中使用。

不使用类或全局变量创建对象的方法如下:

def set_volume(t, buf=bytearray(3)):
    buf[0] = 0xa5
    buf[1] = t >> 4
    buf[2] = 0x5a
    return buf

编译器 buf在第一次加载函数时(通常是在导入它所在的模块时)实例化默认参数。

当创建对绑定方法的引用时,就会发生对象创建的实例。这意味着 ISR 不能将绑定方法传递给函数。一种解决方案是在类构造函数中创建对绑定方法的引用,并在 ISR 中传递该引用。例如:

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)

其他技术是在构造函数中定义和实例化该方法或Foo.bar() 使用参数self传递。

Python对象的使用

由于 Python 的工作方式,对对象的进一步限制出现了。当import执行一条语句时,Python 代码被编译为字节码,一行代码通常映射到多个字节码。当代码运行时,解释器读取每个字节码并将其作为一系列机器代码指令执行。鉴于机器代码指令之间的任何时间都可能发生中断,因此 Python 代码的原始行可能仅部分执行。因此,在主循环中修改的 Python 对象(例如集合、列表或字典)在中断发生时可能缺乏内部一致性。

一个典型的结果如下。在极少数情况下,ISR 会在对象部分更新的精确时刻运行。当 ISR 尝试读取对象时,会导致崩溃。由于此类问题通常发生在罕见、随机的场合,因此很难诊断。有一些方法可以规避此问题,如 下面的 关键部分所述。

重要的是要清楚什么构成了对象的修改。对内置类型(例如字典)的更改是有问题的。改变数组或字节数组的内容不是。这是因为字节或字被写入为不可中断的单个机器代码指令:在实时编程的说法中,写入是原子的。用户定义的对象可能实例化一个整数、数组或字节数组。主循环和 ISR 都可以更改它们的内容。

MicroPython 支持任意精度的整数。2**30 -1 和 -2**30 之间的值将存储在单个机器字中。较大的值存储为 Python 对象。因此,不能将长整数的更改视为原子性的。在 ISR 中使用长整数是不安全的,因为可能会在变量值更改时尝试分配内存。

克服浮动限制

一般来说,最好避免在 ISR 代码中使用浮点数:硬件设备通常处理整数,而转换为浮点数通常在主循环中完成。然而,有一些 DSP 算法需要浮点。在具有硬件浮点的平台(例如 Pyboard)上,可以使用内联 ARM Thumb 汇编程序来解决此限制。这是因为处理器将浮点值存储在机器字中;值可以通过浮点数组在 ISR 和主程序代码之间共享。

使用 micropython.schedule

此函数使 ISR 能够“很快”安排执行回调。回调排队等待执行,这将在堆未锁定时发生。因此它可以创建 Python 对象并使用浮点数。回调也保证在主程序完成 Python 对象的任何更新时运行,因此回调不会遇到部分更新的对象。

典型用途是处理传感器硬件。ISR 从硬件获取数据并使其能够发出进一步的中断。然后它安排一个回调来处理数据。

预定回调应符合下面概述的中断处理程序设计原则。这是为了避免因 I/O 活动和共享数据的修改而导致的问题,这些问题可能出现在任何抢占主程序循环的代码中。

需要根据中断发生的频率来考虑执行时间。如果在执行前一个回调时发生中断,则回调的另一个实例将排队等待执行;这将在当前实例完成后运行。因此,持续的高中断重复率会带来不受约束的队列增长和最终失败的风险RuntimeError.

如果要传递给的回调schedule()是绑定方法,请考虑“创建 Python 对象”中的注释。

例外

如果 ISR 引发异常,它不会传播到主循环。除非异常由 ISR 代码处理,否则中断将被禁用。

一般问题

这仅仅是对实时编程主题的简要介绍。初学者应该注意实时程序中的设计错误会导致特别难以诊断的故障。这是因为它们很少发生,并且间隔基本上是随机的。正确进行初始设计并在问题出现之前对其进行预测至关重要。中断处理程序和主程序的设计都需要考虑以下问题。

中断处理程序设计

如上所述,ISR 应该设计得尽可能简单。他们应该总是在很短的、可预测的时间内返回。这很重要,因为当 ISR 运行时,主循环不是:主循环不可避免地会在代码中的随机点执行暂停。这种暂停可能是难以诊断错误的来源,特别是如果它们的持续时间很长或可变。为了理解 ISR 运行时的含义,需要基本掌握中断优先级。

中断是根据优先级方案组织的。ISR 代码本身可能会被更高优先级的中断所中断。如果两个中断共享数据,这会产生影响(请参阅下面的关键部分)。如果发生这样的中断,它会在 ISR 代码中插入一个延迟。如果在 ISR 运行时发生了较低优先级的中断,则会延迟到 ISR 完成:如果延迟太长,较低优先级的中断可能会失败。慢 ISR 的另一个问题是在其执行期间发生第二个相同类型的中断的情况。第二个中断将在第一个中断结束时处理。但是,如果传入中断的速率始终超过 ISR 为其提供服务的能力,那么结果将不会令人满意。

因此,应该避免或最小化循环结构。通常应避免对中断设备以外的设备进行 I/O:磁盘访问、print 语句和 UART 访问等 I/O相对较慢,其持续时间可能会有所不同。这里的另一个问题是文件系统函数不可重入:在 ISR 和主程序中使用文件系统 I/O 会很危险。至关重要的是,ISR 代码不应等待事件。如果代码可以保证在可预测的时间段内返回,则 I/O 是可以接受的,例如切换引脚或 LED。可能需要通过 I2C 或 SPI 访问中断设备,但应计算或测量此类访问所需的时间,并评估其对应用程序的影响。

通常需要在 ISR 和主循环之间共享数据。这可以通过全局变量或通过类或实例变量来完成。变量通常是整数或布尔类型,或者整数或字节数组(预先分配的整数数组比列表提供更快的访问速度)。在 ISR 修改多个值的情况下,有必要考虑在主程序访问了一些但不是全部值时发生中断的情况。这可能会导致不一致。

考虑以下设计。ISR 将传入数据存储在字节数组中,然后将接收到的字节数添加到表示准备处理的总字节数的整数中。主程序读取字节数,处理字节,然后清除准备好的字节数。这将一直工作,直到主程序读取字节数后发生中断。ISR 将添加的数据放入缓冲区并更新接收到的数字,但主程序已经读取了该数字,因此处理最初接收到的数据。新到达的字节丢失。

有多种方法可以避免这种危险,最简单的方法是使用循环缓冲区。如果不可能使用具有固有线程安全性的结构,则下面描述其他方法。

重入性

如果在主程序和一个或多个 ISR 之间或在多个 ISR 之间共享一个函数或方法,则可能会发生潜在的危险。这里的问题是函数本身可能会被中断,并且该函数的另一个实例会运行。如果发生这种情况,函数必须设计为可重入的。这是如何完成的,这是一个超出本教程范围的高级主题。

关键部分

代码关键部分的一个例子是访问多个可能受 ISR 影响的变量。如果在访问各个变量之间碰巧发生中断,则它们的值将不一致。这是一个被称为竞争条件的危险实例:ISR 和主程序循环竞争改变变量。为避免不一致,必须采用一种方法来确保 ISR 在关键部分的持续时间内不会改变值。实现此目的的一种方法是pyb.disable_irq()在该部分开始之前和 pyb.enable_irq()结束时发出。下面是这种方法的一个例子:

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)

临界区可以包含一行代码和一个变量。考虑以下代码片段。

count = 0
def cb(): # An interrupt callback
    count +=1
def main():
    # Code to set up the interrupt callback omitted
    while True:
        count += 1

这个例子说明了一个微妙的错误来源。主循环中的这一行带有一个特定的竞争条件风险,称为读-修改-写。这是实时系统中错误的典型原因。在主循环中,MicroPython 读取 的值,将其加 1,然后将其写回。在极少数情况下,中断发生在读取之后和写入之前。中断会修改,但当 ISR 返回时,其更改会被主循环覆盖。在实际系统中,这可能会导致罕见的、不可预测的故障。count += 1t.countert.counter

如上所述,如果在主代码中修改了 Python 内置类型的实例并且在 ISR 中访问该实例,则应小心。执行修改的代码应该被视为关键部分,以确保在 ISR 运行时实例处于有效状态。

如果数据集在不同 ISR 之间共享,则需要特别小心。这里的危险在于,当较低优先级的中断部分更新了共享数据时,可能会发生较高优先级的中断。处理这种情况是一个高级主题,超出了本介绍的范围,但要注意有时可以使用下面描述的互斥对象。

在关键部分期间禁用中断是通常且最简单的处理方式,但它禁用所有中断,而不仅仅是可能导致问题的中断。通常不希望长时间禁用中断。在定时器中断的情况下,它引入了回调发生时间的可变性。在设备中断的情况下,它可能导致设备服务太晚,可能会丢失数据或设备硬件中出现溢出错误。像 ISR 一样,主代码中的临界区应该有一个短的、可预测的持续时间。

一种处理临界区的方法,从根本上减少中断被禁用的时间,是使用称为互斥体的对象(名称源自互斥的概念)。主程序在运行临界区之前锁定互斥锁,并在最后将其解锁。ISR 测试互斥锁是否被锁定。如果是,它会避开临界区并返回。设计挑战是定义在拒绝访问关键变量的情况下 ISR 应该做什么。可以在 此处找到互斥锁的简单示例 。请注意,互斥代码确实禁用了中断,但仅限于 8 条机器指令的持续时间:这种方法的好处是其他中断实际上不受影响。

中断和 REPL

中断处理程序,例如与定时器相关的处理程序,可以在程序终止后继续运行。这可能会产生意想不到的结果,您可能期望引发回调的对象超出范围。例如在 Pyboard 上:

def bar():
    foo = pyb.Timer(2, freq=4, callback=lambda t: print('.', end=''))

bar()

这将继续运行,直到明确禁用计时器或使用 重置电路板。ctrl D.