全网最详细|树莓派 Pico ADC 软件控制全解析:从machine.ADC API 到定时温度采集实战(含 ISR 避坑指南)

1.软件控制方法

1.1 machine.ADC 类的构造方法

machine.ADC 类的构造方法如下:

1.2 machine.ADC 类的其他方法

machine.ADC 类还有其他方法:

这里需要注意的点是:

  • 使用树莓派 Pico 的 ADC 外设时,在构造方法中,只能使用整数 id 号进行(即 ADC 通道号)
  • 树莓派 Pico 的 ADC 类中有一个 CORE_TEMP 属性,用于指定访问内置温度传感器的特定 ADC 通道:

树莓派 Pico 上有三个可用于模拟输入的 ADC 引脚,它们分别是:

  • GP26 (ADC0):物理引脚 31
  • GP27 (ADC1):物理引脚 32
  • GP28 (ADC2):物理引脚 34

树莓派 Pico 的 ADC 引脚能够读取 0 到 3.3V 之间的模拟电压,并将其转换为 16 位的数字值(0 到 65535),这些引脚可以用来读取模拟传感器的信号,比如电位器、光敏电阻等。

2.应用实例:定时采集内部温度传感器数据

2.1 定时采集内部温度传感器示例

在以下代码中,我们通过 MicroPython 的定时器定期采集内部温度传感器数据并在主程序中打印出来。

以下代码可以在我们提供的资料包中 elegance-devkit v1\Demo\57 ADC_Temp 文件夹找到。

示例程序如下:

Python 复制代码
# Python env   : MicroPython v1.23.0
# -*- coding: utf-8 -*-        
# @Time    : 2024/8/26 上午10:31   
# @Author  : 李清水            
# @File    : main.py       
# @Description : ADC类实验,定时采集内部温度传感器数据

# ======================================== 导入相关模块 ========================================

# 导入硬件模块
from machine import ADC, Timer
# 导入时间相关模块
import time
# 导入双端队列
from collections import deque
# 导入访问和控制 MicroPython 内部结构的模块
import micropython

# ======================================== 全局变量 ============================================

# 电压转换系数
conversion_factor = 3.3 / (65535)
# 创建一个空的 deque 对象,用于保存温度数据
# temperature_list 是用于初始化双端队列的可迭代对象
temperature_list = []
# 10 是 deque 的最大长度,1 是 flags 参数,表示开启溢出检查
temperature_deque = deque(temperature_list, 10, 1)
# 标记开始时间
start_time = time.ticks_ms()

# ======================================== 功能函数 ============================================

# 定时器回调函数
def timer_callback(timer: Timer) -> None:
    """
​    定时采集内部温度传感器数据的回调函数。

​    Args:
​        timer (machine.Timer): 定时器对象。

​    Returns:
​        None
​    """
​    ​# 使用micropython.schedule解决动态内存分配的问题
    micropython.schedule(collect_temperature, 0)

# 采集温度数据函数
def collect_temperature(t: int) -> None:
    """
​    真正用于采集温度数据的函数。

​    Args:
​        t (int): 无实际用途,仅为满足 micropython.schedule 的函数签名要求。

​    Returns:
​        None
​    """
​    ​# 声明全局变量
    global sensor_temp, temperature_deque, conversion_factor, start_time

    # 获取当前时间
    current_time = time.ticks_ms()
    # 计算采集时间间隔
    time_interval = time.ticks_diff(current_time, start_time)
    # 打印时间间隔
    print("Time interval: {}ms".format(time_interval))

    # 读取温度传感器的原始数据
    reading = sensor_temp.read_u16() * conversion_factor
    # 计算温度(摄氏度)
    temperature = 27 - (reading - 0.706) / 0.001721
    # 将温度数据放入双端队列
    temperature_deque.append(temperature)

# ======================================== 自定义类 ============================================

# ======================================== 初始化配置 ==========================================

# 上电延时
time.sleep(3)
# 打印调试信息
print("FreakStudio : Get internal temperature data regularly")

# 初始化内部温度传感器
sensor_temp = ADC(4)
# 初始化定时器,每隔3秒采集一次数据
timer = Timer(-1)
timer.init(period=3000, mode=Timer.PERIODIC, callback= timer_callback)

# ========================================  主程序  ===========================================

# 主程序循环
while True:

    # 当ADC采集到新的数据时
    if len(temperature_deque) > 0:
        # 从双端队列中获取并打印最新的温度数据
        # 此时temperature_deque为一个FIFO先进先出队列
        temperature = temperature_deque.popleft()
        # 打印温度数据
        print("Temperature: {:.2f}°C".format(temperature))

    # 主程序每秒检查一次队列
    time.sleep(1)

这里,我们进行了如下工作:

  • 定时采集温度数据 :初始化定时器 timer 为周期性定时器,每 3 秒调用一次 timer_callback 函数,在 timer_callback 函数中使用 micropython.schedule 函数执行 collect_temperature 函数,实际的数据的获取以及处理在 collect_temperature 函数中完成,并将采集并转换后得到的温度数据插入 temperature_deque 队列中
  • 主程序打印温度数据:主程序循环每秒检查一次双端队列,如果队列中有数据,从队列中取出并打印最新的温度数据

需要注意的有以下几点:

  • 使用 ​ micropython.schedule执行数据处理 :在 MicroPython 中,定时器回调函数通常执行时间很短,若回调函数内的操作复杂或耗时长,可能会阻塞系统,通过使用 micropython.schedule 函数可以将执行的实际工作被推迟到主程序上下文中尽可能快地进行;同时,中断回调函数中禁止内存分配(无法在中断中创建 Python 对象如列表队列等,也无法执行插入操作,或执行浮点运算),micropython.schedule 函数可以在主程序中安全地进行内存分配,而不会影响中断的稳定性和速度

  • 使用 collections.deque双端队列保存和读取数据deque 是一个先进先出的数据结构,这非常适合用于存储随时间变化的温度数据,每次新的温度数据进入时,会被添加到队列的末尾,而最早的温度数据会被从队列头部移出,我们可以得到一个随时间顺序变化的温度序列.

  • 在 MicroPython 中创建 collections.deque双端队列对象:这里,和在上位机的 CPython 中创建双端队列对象有所不同,,这些限制与 MicroPython 的设计目标(嵌入式系统和资源有限的环境)相关

  • 必须传入用于初始化队列的可迭代对象,这里常用列表进行

  • 必须设置队列的最大长度 maxlen 的值

  • 可以设置 flags 参数用于控制队列的溢出检查:

    • flags=1 开启溢出检查,尝试添加新元素时,如果队列已满会引发 IndexError
    • 如果不需要溢出检查,可以将其设置为 0

烧录代码,连接远程终端,输出如下:

可以看到,能够成功定时采集并打印温度数据。

2.2 MicroPython 中断回调函数注意事项

在 MicroPython 的中断回调函数中,需要注意以下信息:

  1. 保持代码尽可能简短和简单 :ISR 执行时,中断了主程序的运行,执行过长或复杂的 ISR 代码会导致主程序长时间无法继续执行,可能引发系统不稳定或响应延迟;ISR 代码应当只完成必须在中断事件发生后立即执行的任务。可以推迟的操作应当委托给主程序循环处理。 通常,ISR 的作用是处理引发中断的硬件设备,使其为下一次中断做好准备,ISR 会通过更新共享数据与主循环进行通信,以指示中断已经发生,并尽快返回主循环
  2. 避免内存 分配,不在ISR 中追加列表或插入字典,不使用浮点运算:在 ISR 中进行动态内存分配或复杂的计算(如浮点运算)可能会触发垃圾收集器(GC),导致系统停顿甚至崩溃;ISR 在执行时不能保证有足够的资源来安全地完成这些操作,因此应避免在 ISR 中进行任何形式的动态内存分配或复杂计算
  3. 考虑使用 micropython.schedule 来解决上述限制micropython.schedule 允许将较重的工作转移到主程序中执行,而不是在 ISR 中直接完成;这种方式可以绕过 ISR 中的内存分配限制,并减少 ISR 的执行时间
  4. 在ISR 返回多个字节时,使用预分配的 bytearray ;如果多个整数需要在 ISR 和主程序之间共享,考虑使用 array.array :预分配的 bytearrayarray.array 避免了在 ISR 中进行动态内存分配,同时提供了足够的空间来存储和共享数据
  5. 当主程序访问与ISR 共享的数据时,考虑在访问数据前禁用中断,并在之后立即重新启用中断(参见关键区域):ISR 和主程序可能同时访问相同的数据,导致数据竞争问题。通过在访问共享数据时禁用中断,可以避免 ISR 在主程序操作共享数据时打断,从而防止数据不一致或损坏,访问完成后立即恢复中断,确保系统不会错过重要的中断事件。
  6. 分配紧急异常缓冲区:在 ISR 中,如果发生异常且没有足够的内存来存储异常信息,异常可能被忽略;分配紧急异常缓冲区可以确保即使在内存受限的情况下,异常信息也能被存储并输出,帮助开发人员在调试时识别和解决问题

这些建议旨在最大限度地提高 ISR 的效率和可靠性,确保嵌入式系统在处理中断时能够稳定运行并快速响应。

2.2.1 MicroPython 中断回调函数的限制

在 MicroPython 中,中断处理和垃圾收集器(gc)之间有一种特殊的关系,主要体现在内存分配的限制上。具体来说:

  1. 垃圾收集器的锁定:当垃圾收集器运行时,MicroPython 会锁定内存分配过程,以确保内存管理的安全性。这意味着在垃圾收集器运行时,程序无法进行动态内存分配操作。这对于中断服务程序(ISR)来说尤为重要,因为它们通常在程序运行的任何时间点都可能被触发
  2. ISR中的 内存分配限制 :由于中断可能在任何时候触发,包括在垃圾收集器运行时,MicroPython 设计了一个约束:ISR 中不允许进行 动态内存分配 **,这是为了避免在 ISR 执行时,垃圾收集器锁定内存分配导致的潜在问题。**如果在 ISR 中尝试分配内存,而此时垃圾收集器正好锁定了内存分配,程序可能会发生异常甚至崩溃
  3. 预先分配内存:为了规避这种问题,开发人员需要在 ISR 触发前就分配好所有需要的内存资源。这意味着所有用于 ISR 的数据结构、对象和内存块都应在主程序中提前分配好,并在 ISR 中直接使用,而不是在 ISR 中动态创建新的对象或分配新的内存

这里,我们也不能进行浮点运算,因为浮点数是 Python 对象,创建浮点数需要分配内存,而这是在 ISR 中被禁止的;同样地,ISR 也不能向列表中追加元素(append),因为这会涉及到内存分配。

2.2.2 MicroPython 紧急异常缓冲区

在 MicroPython 中,开发人员可以通过预分配一定量的内存来存储中断服务程序(ISR)中发生的异常信息,包括回溯信息。这种做法能够在 ISR 中遇到问题时,将异常信息存储在预先分配的缓冲区中,然后在适当的时候输出到 REPL(Read-Eval-Print Loop)终端,帮助开发人员调试和纠正问题。

这是通过在 main.pyboot.py 的顶部添加以下代码来完成的:

Python 复制代码
import micropython
micropython.alloc_emergency_exception_buf(100)

以上代码预先分配了 100 字节的缓冲区,用于存储在 ISR 中可能抛出的异常信息,MicroPython 文档推荐 100 字节作为合适的缓冲区大小。**然而,紧急异常缓冲区只能存储一个异常的堆栈跟踪信息。**这意味着,如果在处理第一个异常的过程中再次抛出另一个异常,那么第二个异常的堆栈跟踪信息会覆盖掉原先存储的第一个异常的堆栈跟踪信息。这可能导致开发者在调试时无法获取第一个异常的准确信息,从而引发混淆和误导。

2.2.3 ISR 和主程序之间的通信

ISR(中断服务程序)和主程序之间的通信通常需要通过共享的数据对象进行。最简单的方式是使用全局变量或通过类共享这些数据对象。常见的共享数据对象包括整数、字节(bytes)、字节数组(bytearray)以及可以存储各种数据类型的数组(array 模块中的 array 对象)。这种方法可以让 ISR 在处理完中断事件后,迅速将状态或数据传递给主程序,以便主程序能够做出相应的反应。

2.2.4 使用对象方法作为回调函数

使用对象方法作为回调函数是一种强大的技术,MicroPython 支持这一特性,它可以让 ISR(中断服务程序)与实例变量共享数据;这种方式不仅能够让类实现多个设备实例的驱动程序,还能够在 ISR 中轻松地访问和更新实例的状态。

以下示例使两个 LED 以不同的速率闪烁:

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

# 创建两个实例,每个实例关联不同的定时器和 LED
red = Foo(pyb.Timer(4, freq=1), pyb.LED(1))
green = Foo(pyb.Timer(2, freq=0.8), pyb.LED(2))

在这个示例中,Foo 类的 cb 方法是一个实例方法,它被设定为定时器的回调函数。每当定时器触发中断时,对应实例的 cb 方法就会被调用:

  • 当定时器 4 触发中断时,red 实例的 cb 方法会被调用,从而使 LED 1 状态切换(点亮或熄灭)
  • 同样,当定时器 2 触发中断时,green 实例的 cb 方法会被调用,使 LED 2 状态切换

这种设计的一个主要好处是,它允许使用相同的类 Foo 来控制多个硬件实例;在这个例子中,Foo 类能够同时管理定时器 4 和定时器 2 以及对应的 LED。由于 cb 是一个绑定方法,它的第一个参数是 self,这使得回调函数能够访问类的实例变量。例如,如果在 Foo 类的构造函数中有一个 self.count 变量,cb 方法就可以在每次调用时增加这个计数器。这意味着 redgreen 实例可以分别维护独立的 LED 状态切换次数。

通过使用对象方法作为回调 ​**,多个硬件实例可以共享相同的代码,减少了代码量,提高了代码的可维护性。**

2.2.5 ISR 中间接 "创建" Python 对象

在 MicroPython 中,ISR(中断服务程序)不能创建 Python 对象。这是因为 MicroPython 需要从一个称为堆(heap)的自由内存块存储中为对象分配内存。而堆内存分配在中断处理程序中是不允许的,因为堆分配不是可重入的。换句话说,中断可能会在主程序正在执行内存分配时发生,为了保持堆的完整性,解释器在 ISR 代码中禁止进行内存分配。

我们有以下几种方案可以间接解决这个问题:

  • ​预先分配的缓冲区:​ 一种避免此问题的方法是在 ISR 中使用预先分配的缓冲区,例如,类的构造函数可以创建一个 bytearray 实例和一个布尔标志;ISR 方法可以将数据分配到缓冲区中的特定位置,并设置标志。这样内存分配是在主程序代码中对象被实例化时发生的,而不是在 ISR 中。示例代码如下,其中类的构造函数预先分配了一个 bytearray 实例,并在 ISR 中使用它:
Python 复制代码
import pyb

class SensorReader:
    def __init__(self):
        # 预先分配一个缓冲区,用于存储数据
        self.buffer = bytearray(3)
        self.data_ready = False  # 布尔标志,用于指示数据是否已准备好

    def read_sensor(self):
        # 模拟读取传感器数据并存储到缓冲区中
        self.buffer[0] = 0x01  # 假设的传感器数据
        self.buffer[1] = 0x02
        self.buffer[2] = 0x03
        self.data_ready = True  # 设置标志,表示数据已准备好

    def get_data(self):
        if self.data_ready:
            return self.buffer
        return None

# 创建实例
sensor = SensorReader()

# ISR示例
def timer_callback(timer):
    sensor.read_sensor()  # 在ISR中使用预先分配的缓冲区

# 设置定时器,每隔2秒调用一次ISR
tim = pyb.Timer(4, freq=0.5)
tim.callback(timer_callback)

# 主程序循环
while True:
    data = sensor.get_data()
    if data:
        print("Sensor data:", list(data))
    pyb.delay(1000)
  • ​MicroPython 库中的 I/O 方法:​ MicroPython 库的 I/O 方法通常提供了使用预分配缓冲区的选项。例如,pyb.i2c.recv() 可以接受一个可变缓冲区作为其第一个参数,这使得它可以在 ISR 中使用。
Python 复制代码
import pyb

# 初始化I2C
i2c = pyb.I2C(1, pyb.I2C.MASTER, baudrate=100000)

# 预先分配一个缓冲区
buffer = bytearray(10)

def i2c_callback(timer):
    i2c.recv(buffer, addr=0x42)  # 在ISR中使用预分配的缓冲区

# 设置定时器,每隔1秒调用一次ISR
tim = pyb.Timer(4, freq=1)
tim.callback(i2c_callback)

# 主程序循环
while True:
    print("Received I2C data:", list(buffer))
    pyb.delay(1000)
  • ​使用默认参数创建对象:​在不使用类或全局变量的情况下,可以通过默认参数来创建对象。这可以避免在 ISR 中分配内存
Python 复制代码
def set_volume(t, buf=bytearray(3)):
    buf[0] = 0xa5
    buf[1] = t >> 4
    buf[2] = 0x5a
    return buf

# 示例使用
volume_data = set_volume(10)
print("Volume data:", list(volume_data))
  • ​引用绑定方法:​在 MicroPython 的 ISR 中直接传递绑定方法的引用会导致内存分配问题,因为在 ISR 中不允许进行动态内存分配,以下是一个反例,展示了在 ISR 中直接传递绑定方法的引用可能引发的错误
Python 复制代码
import pyb

class Foo:
    def __init__(self):
        self.x = 0.1
        tim = pyb.Timer(4)
        tim.init(freq=2)
        tim.callback(self.cb)

    def cb(self, t):
        self.x *= 1.2
        print(self.x)

foo_instance = Foo()

在上述代码中,tim.callback(self.cb) 直接将 self.cb(一个绑定方法)传递给了定时器的回调函数。当定时器触发中断时,MicroPython 将尝试执行这个回调函数。然而,self.cb 是一个绑定方法的引用,而在调用这个绑定方法时,MicroPython 需要在堆上分配内存来处理这个调用的上下文(即绑定到实例的 self)。由于 ISR 中禁止内存分配,因此这种直接传递绑定方法引用的操作可能会导致错误,甚至使系统崩溃。

为了避免在 ISR 中直接传递绑定方法的引用,可以在类的构造函数中创建该引用并在 ISR 中使用它

Python 复制代码
import pyb
import micropython

class Foo:
    def __init__(self):
        self.x = 0.1
        self.cb_ref = self.cb  # 在构造函数中预先创建方法引用
        tim = pyb.Timer(4)
        tim.init(freq=2)
        tim.callback(self.cb_wrapper)

    def cb(self, t):
        self.x *= 1.2
        print(self.x)

    def cb_wrapper(self, t):
        micropython.schedule(self.cb_ref, 0)  # 在ISR中使用预先创建的方法引用

foo_instance = Foo()

2.2.6 减少浮点数的应用

在 MicroPython 的开发中,通常建议避免在 ISR(中断服务程序)代码中使用浮点数。这是因为浮点数在 Python 中是对象类型,而对象的创建需要内存分配。尽管如此,有些特定的数字信号处理(DSP)算法确实需要使用浮点数。在这种情况下,可以利用硬件支持的浮点运算来绕过上述限制。

某些 MicroPython 支持的硬件平台(如 Pyboard)提供了硬件浮点单元(FPU),可以在不触发内存分配的情况下进行浮点运算。此时,我们可以使用嵌入式的 ARM Thumb 汇编语言来直接在 ISR 中进行浮点运算。

2.2.7 使用 micropython.schedule

在 MicroPython 中,micropython.schedule 是一个非常有用的函数,它允许在 ISR(中断服务程序)中安排一个回调函数,使其在"很快"之后执行。这种方法的主要优势在于,它可以延迟执行回调,直到系统处于一个可以安全进行 Python 对象创建和浮点运算的状态。

一个常见的使用场景是处理传感器硬件。当硬件触发中断时,ISR 从硬件获取数据,并设置硬件以便在下次准备好时触发新的中断。获取数据后,ISR 不会直接处理数据,而是通过 micropython.schedule 安排一个回调函数来处理这些数据。

尽管使用了 micropython.schedule回调函数仍然需要遵守中断处理程序设计的基本原则,以避免可能的I/O活动冲突和共享数据修改问题。回调函数不应该进行长时间的操作,因为这些操作会阻塞主程序的执行,从而影响系统性能。

在设计回调函数时,必须考虑中断的频率。如果中断发生得过于频繁,而前一个回调函数还没有执行完,那么新触发的中断将安排另一个回调函数进入队列,这可能导致队列不断增长。长时间维持这种高频中断可能会导致队列溢出,并最终导致 RuntimeError(运行时错误)。因此,在高频中断情况下,需要特别小心,以确保回调函数的执行时间尽可能短,并且在设计时考虑到系统的性能限制。

如果传递给 micropython.schedule 的回调函数是一个绑定方法,需要注意在创建 Python 对象时的内存分配问题。由于绑定方法涉及类实例中的对象,因此在 ISR 中传递绑定方法引用可能会触发内存分配,这是在 ISR 中需要避免的。可以通过在类的构造函数中预先创建绑定方法引用,或者使用 micropython.schedule 安排非绑定方法来解决这个问题。

以下是一个使用 micropython.schedule 的简单示例,展示了如何在 ISR 中获取传感器数据,并安排一个回调函数在稍后处理这些数据:

Python 复制代码
import micropython
import pyb

# 初始化一个全局变量用于存储传感器数据
sensor_data = 0

# 预先分配的缓冲区,用于存储ISR中获取的数据
buffer = bytearray(4)

# 定义一个回调函数,用于处理获取的数据
def process_data(dummy):
    global sensor_data
    # 在回调函数中可以安全地处理数据
    print("Processing data:", sensor_data)

# 定义ISR,用于从传感器获取数据并安排回调函数
def sensor_isr(timer):
    global sensor_data
    # 假设在ISR中获取传感器数据
    sensor_data = pyb.rng()  # 示例:随机数模拟传感器数据
    # 安排回调函数在稍后执行
    micropython.schedule(process_data, None)

# 设置定时器,每秒触发一次中断
tim = pyb.Timer(4, freq=1)
tim.callback(sensor_isr)

# 主程序循环
while True:
    pyb.delay(1000)

2.2.8 ISR 中的异常处理

在 MicroPython 中,如果一个中断服务程序(ISR)抛出了一个异常,这个异常不会像普通的 Python 代码那样传播到主程序的主循环中。这意味着主程序不会自动捕获和处理这个异常。相反,发生异常的中断将被禁用,除非在 ISR 内部对异常进行了处理。

其异常抛出和处理的步骤如下:

  1. 异常不会传播到主循环
    1. 在普通的 Python 代码中,当一个异常被抛出时,如果没有在当前上下文中处理它,异常会沿着调用栈向上传播,直到找到合适的异常处理器(try...except 块)。如果没有找到合适的处理器,异常将导致程序崩溃,并输出一个回溯信息
    2. 然而,在 ISR 中,情况不同。由于 ISR 是在特定的硬件事件触发下执行的特殊代码段,它运行在一个与主程序不同的上下文中。如果在 ISR 中抛出异常,异常不会像在普通 Python 代码中那样沿着 调用栈 传播到主程序。相反,异常会被限制在 ISR 内部,不会传递到主程序的主循环
  2. 中断将被禁用
    1. ISR 抛出未处理的异常时,MicroPython 会自动禁用该中断。这意味着之后同样的硬件事件将不会再次触发 ISR,直到你手动重新启用这个中断
    2. 这样做的目的是为了保护系统的稳定性。如果允许未处理的异常在 ISR 中不断发生,可能会导致系统进入不稳定状态,甚至崩溃
  3. 在ISR 内部处理异常
    1. 为了避免中断被禁用,可以在 ISR 内部使用 ​ try...except **​ 块来捕获和处理异常。**这可以防止中断被禁用,同时允许开发者在发生错误时执行一些补救措施,例如记录错误信息或重试操作

假设有一个 ISR 用于处理一个定时器中断,该 ISR 会访问某个硬件设备。如果在访问设备时发生了错误(例如设备未响应),可能会抛出一个异常。为了防止中断被禁用,可以在 ISR 内部捕获这个异常并进行处理:

Python 复制代码
import pyb

def isr_handler(timer):
    try:
        # 尝试访问硬件设备
        data = read_device_data()  # 假设这是一个可能抛出异常的函数
        process_data(data)
    except Exception as e:
        # 捕获并处理异常,防止中断被禁用
        print("Error in ISR:", e)
        # 这里可以添加更多的错误处理逻辑,如重试、记录日志等

# 设置定时器中断
tim = pyb.Timer(4, freq=1)
tim.callback(isr_handler)

在这个示例中,如果 read_device_data() 函数在 ISR 中抛出异常,异常将被捕获,并输出错误信息到 REPL 控制台;这样,定时器中断不会被禁用,ISR 仍然可以在下一次硬件事件触发时执行。

2.2.9 异步 IO 与 ISR

在 MicroPython 中,当一个中断服务程序(ISR)运行时,它可能会抢占(即中断)asyncio 调度器的运行。如果在 ISR 中执行了 asyncio 操作,这可能会干扰调度器的正常运行。这种情况无论是在硬中断(硬件触发的中断)还是软中断(软件触发的中断)中都会发生。即使 ISR 通过 micropython.schedule 将执行权转移给另一个函数,这个问题依然存在。特别需要注意的是,在 ISR 上下文中创建或取消 ​**asyncio** ​任务是无效的。

asyncio 安全交互的方式是实现一个协程(coroutine),并通过 asyncio.ThreadSafeFlag 来进行同步,这种方法能够确保在 ISR 和 asyncio 调度器之间的通信不会导致调度器的紊乱。

以下代码片段展示了如何在响应中断时创建一个 asyncio 任务:

Python 复制代码
import asyncio

tsf = asyncio.ThreadSafeFlag() # 用于线程安全同步的标志对象

def isr(_):  # 中断处理程序
    tsf.set()  # 设置同步标志,通知协程中断发生

async def foo():
    while True:
        await tsf.wait()  # 等待中断信号
        asyncio.create_task(bar())  # 创建并运行bar任务

ThreadSafeFlag 是一个用于线程安全同步的标志对象。它允许在 ISR 中设置一个信号,然后在协程中等待这个信号。这种机制确保了在中断与 asyncio 之间的通信是安全的,不会导致调度器问题。

2.2.10 ISR 中断服务程序编写注意要点

2.2.10.1 中断处理程序设计

如下所示:

  • 简洁性和快速返回:ISR 应设计得尽可能简单,并且在短时间内返回。因为在 ISR 执行时,主循环不会运行,这可能导致主循环在随机位置经历暂停。这样不确定的暂停可能导致难以诊断的错误,特别是当暂停时间较长或不稳定时。
  • 中断优先级:中断通常根据优先级进行组织。较高优先级的中断可以中断较低优先级的中断。这会影响中断处理程序之间的数据共享(见"临界区"部分)。当 ISR 执行时,如果发生另一个高优先级的中断,当前 ISR 将被中断,这可能会导致延迟。如果在 ISR 执行过程中发生了第二个同类型的中断,第二个中断将在第一个中断处理完毕后进行处理。如果中断频率超出了 ISR 处理的能力,可能会导致 ISR 无法跟上,造成系统故障。
  • 避免循环结构和I/O 操作:ISR 中应尽量避免循环结构。I/O 操作(如磁盘访问、打印语句、UART 访问)相对较慢,且其持续时间可能会变化。文件系统函数在 ISR 中不可重入(即文件系统 I/O 在 ISR 和主程序中使用是危险的)。ISR 代码应避免等待事件。可以接受的 I/O 操作是那些能够保证在可预测的时间内返回的操作,例如切换引脚或 LED。通过 I2C 或 SPI 访问中断设备可能是必要的,但其访问时间应计算或测量,并评估对应用程序的影响。
  • 共享数据:通常需要在 ISR 和主循环之间共享数据。这可以通过全局变量或类/实例变量来实现。常用的变量类型包括整数、布尔值或整数/字节数组(预分配的整数数组访问速度快于列表)。如果 ISR 修改了多个值,需要考虑 ISR 在主程序访问某些但非所有值的时间点发生的情况。这可能导致数据不一致。

这里,举一个共享数据的例子:

Python 复制代码
import pyb
import array
import micropython

ARRAYSIZE = 20
index = 0
data = array.array('i', [0] * ARRAYSIZE)

def callback1(t):
    global data, index
    for x in range(5):
        data[index] = pyb.rng()  # 模拟输入
        index += 1
        if index >= ARRAYSIZE:
            raise Exception('Array bounds exceeded')

tim4 = pyb.Timer(4, freq=100, callback=callback1)

for loop in range(1000):
    if index > 0: # 这里容易出问题
        irq_state = pyb.disable_irq()  # 临界区开始
        for x in range(index):
            print(data[x])
        index = 0
        pyb.enable_irq(irq_state)  # 临界区结束
        print('loop {}'.format(loop))
    pyb.delay(1)

tim4.callback(None)

上述代码中,ISR 将数据存储在 data 数组中,并更新 index。主循环读取数据并重置 index;如果 ISR 发生在主程序读取 index 但还未处理数据的时候,会导致数据丢失。

在主循环中,pyb.disable_irq()pyb.enable_irq() 包裹了对共享数据的访问,防止 ISR 在此期间修改数据。

2.2.10.2 可重入性

如果一个函数或方法在主程序和一个或多个 ISR 之间共享,可能会发生危险。这是因为函数可能会被中断,然后运行另一个实例。这样的函数必须设计为可重入的,以避免在中断时破坏数据一致性。

可重入(Reentrant)描述了函数或方法在被中断或并发执行时仍能正常工作的能力,其特点为:

  1. 不使用静态或全局变量:
    1. 可重入函数不应该依赖于任何外部状态,因为在被中断时这些状态可能会发生变化
    2. 函数应该仅依赖于其输入参数和局部变量
  2. 不修改自身的状态:
    1. 函数不应该修改自身的内部状态,因为在被中断时这些状态可能会被其他实例覆盖
    2. 如果需要保存状态,应该使用输入参数或局部变量
  3. 不调用不可重入的函数:
    1. 可重入函数不应该调用不可重入的函数,否则会破坏整体的可重入性
    2. 尽量使用可重入的底层函数或库
2.2.10.3 临界区

临界区是指访问多个变量的代码,这些变量可能会被 ISR 影响。如果中断发生在对变量的多个访问之间,它们的值可能会不一致。这是一个竞态条件的例子,即 ISR 和主程序循环竞争修改变量。

避免不一致的一种方法是在临界区的开始使用 pyb.disable_irq(),在结束时使用 pyb.enable_irq()。这样可以防止 ISR 在临界区内修改数据。临界区应尽量简短且可预测,以减少对系统的影响。

也可以使用互斥量,互斥量(mutex)是一种锁机制,用于控制对共享资源的访问。主程序在进入临界区之前锁定互斥量,并在结束后解锁。ISR 检查互斥量是否被锁定,如果是,则跳过临界区。这种方法减少了中断禁用的时间,通常不会对其他中断产生影响。互斥量通常由专门的锁机制实现,如 threading.Lock

2.2.11 ISR 中断和 REPL

在 MicroPython 中,当程序终止后,中断处理程序(如与定时器相关联的处理程序)可能会继续运行。这可能会产生意想不到的结果,特别是在你认为触发回调的对象已经超出作用域时。例如,在 Pyboard 上:

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

bar()

在这个例子中,定时器会继续运行,并且每秒钟打印 4 个点(.),即使 bar() 函数已经执行完毕并退出。这是因为定时器对象 foo 虽然在函数 bar() 中创建,但它不会因为 bar() 函数的结束而被自动销毁或停止。定时器会继续调用回调函数,直到你显式地禁用定时器或通过按下 Ctrl+D 重置开发板。

简单来说,就是如果你不手动禁用定时器或其他中断处理程序,它们会在程序退出后继续运行。

我们可以调用 deinit() 方法将禁用定时器并停止回调函数的执行。