撰寫中斷處理常式

在合適的硬體上,MicroPython 提供以 Python 撰寫中斷處理常式的能力。中斷處理常式(又稱中斷服務常式,ISR)是定義為回呼函式(callback)。它們會回應某個事件而被執行,例如計時器觸發或接腳上的電壓變化。這類事件可能在程式碼執行的任何時刻發生,這帶來了重大影響,其中部分是 MicroPython 語言所特有的,其他則是所有能夠回應即時事件的系統所共通的。本文件先說明語言特有的問題,接著為初學者簡要介紹即時程式設計。

本介紹使用了「慢」或「盡可能快」等模糊的詞彙。這是刻意的,因為速度取決於應用情境。ISR 可接受的執行時間長短,取決於中斷發生的頻率、主程式的性質,以及是否存在其他並行事件。

MicroPython 相關問題

緊急例外緩衝區

若 ISR 中發生錯誤,除非為此目的建立了特殊的緩衝區,否則 MicroPython 無法產生錯誤報告。若在任何使用中斷的程式中加入下列程式碼,除錯將會更為簡便。

import micropython

micropython.alloc_emergency_exception_buf(100)

緊急例外緩衝區只能保存一筆例外的堆疊追蹤(stack trace)。這表示若在處理某個例外的過程中、且堆積(heap)處於鎖定狀態時拋出第二個例外,則該第二個例外的堆疊追蹤將取代原本的那一筆——即使第二個例外已被妥善處理也是如此。若稍後印出該緩衝區,這可能導致令人困惑的例外訊息。

簡潔性

基於種種原因,讓 ISR 程式碼盡可能簡短而單純是很重要的。它只應做那些必須在觸發事件後立即完成的事:可以延後處理的操作應委派給主程式迴圈。通常 ISR 會處理觸發中斷的硬體裝置,使其準備好接收下一次中斷。它會透過更新共享資料來與主迴圈溝通,以指示中斷已經發生,然後返回。ISR 應盡快將控制權交還給主迴圈。這並非 MicroPython 特有的問題,因此會在 下文 中更詳細地說明。

ISR 與主程式之間的溝通

通常 ISR 需要與主程式溝通。最簡單的做法是透過一個或多個共享資料物件,這些物件可宣告為全域變數,或透過類別共享(見下文)。這樣做存在各種限制與風險,下文會更詳細說明。整數、bytesbytearray 物件常被用於此目的,以及可儲存各種資料型別的陣列(來自 array 模組)。

將物件方法作為回呼使用

MicroPython 支援這項強大的技術,使 ISR 能與底層程式碼共享實例變數。它也讓實作裝置驅動程式的類別能夠支援多個裝置實例。下列範例使兩個 LED 以不同的速率閃爍。

import machine
import micropython

micropython.alloc_emergency_exception_buf(100)


class Foo(object):
    def __init__(self, freq, led):
        self.led = led
        self.timer = machine.Timer(-1, freq=freq, callback=self.cb, hard=True)

    def cb(self, tim):
        self.led.toggle()


red = Foo(1, machine.LED("LED_RED"))
green = Foo(0.8, machine.LED("LED_GREEN"))

在此範例中,red 實例由 1 Hz 的虛擬計時器驅動紅色 LED:每次計時器觸發時就會呼叫 red.cb(),切換紅色 LED 的狀態。green 實例以類似方式運作,由 0.8 Hz 的計時器切換綠色 LED。使用實例方法帶來兩項好處。首先,單一類別讓程式碼能在多個硬體實例之間共享。其次,作為繫結方法(bound method),回呼函式的第一個引數即為 self,這使回呼能夠存取實例資料,並在連續呼叫之間保存狀態。例如,若上述類別在建構函式中將變數 self.count 設為零,cb() 便可遞增此計數器。如此一來,redgreen 實例便能各自維護每個 LED 改變狀態的次數的獨立計數。

Python 物件的建立

ISR 無法建立 Python 物件的實例。這是因為 MicroPython 需要從一塊稱為 heap(堆積)的自由記憶體區塊儲備中為物件配置記憶體。這在中斷處理常式中是不被允許的,因為堆積配置並非可重入(re-entrant)。換言之,中斷可能在主程式正進行配置的中途發生——為維護堆積的完整性,直譯器禁止在 ISR 程式碼中配置記憶體。

這帶來的一個後果是 ISR 無法使用浮點運算;這是因為浮點數是 Python 物件。同理,ISR 也無法對串列附加項目。實務上,要精確判斷哪些程式碼結構會嘗試進行記憶體配置並觸發錯誤訊息可能相當困難:這又是讓 ISR 程式碼簡短而單純的另一個理由。

避免此問題的一種方法是讓 ISR 使用預先配置的緩衝區。例如,類別建構函式建立一個 bytearray 實例和一個布林旗標。ISR 方法將資料指派到緩衝區中的位置並設定旗標。記憶體配置發生在物件實例化時的主程式碼中,而非在 ISR 內。

MicroPython 函式庫的 I/O 方法通常提供使用預先配置緩衝區的選項。例如 machine.I2C.readfrom_into() 會讀入呼叫者提供的可變緩衝區:這使其能在 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
        self.tim = machine.Timer(-1, freq=2, callback=self.cb, hard=True)

    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)

其他技術包括在建構函式中定義並實例化該方法,或以引數 self 傳遞 Foo.bar()

Python 物件的使用

由於 Python 的運作方式,物件還有一項進一步的限制。當執行 import 陳述式時,Python 程式碼會被編譯為 bytecode(位元組碼),通常一行程式碼對應到多個位元組碼。當程式碼執行時,直譯器讀取每個位元組碼,並將其作為一系列機器碼指令來執行。由於中斷可能在任意兩條機器碼指令之間的任何時刻發生,原本那一行 Python 程式碼可能只被部分執行。因此,在主迴圈中被修改的 Python 物件(例如集合、串列或字典)在中斷發生的當下可能缺乏內部一致性。

典型的後果如下。在罕見的情況下,ISR 恰好在物件被部分更新的精確時刻執行。當 ISR 嘗試讀取該物件時,便會導致當機。由於這類問題通常在罕見且隨機的時機發生,因此很難診斷。有方法可以規避此問題,詳見下文的 臨界區段

釐清什麼構成「物件的修改」是很重要的。變更陣列或 bytearray 的內容是安全的。這是因為位元組或字組是以單一機器碼指令寫入的,該指令不可被中斷:以即時程式設計的術語來說,此寫入是不可分割的(atomic,原子性)。更新字典項目也是如此,因為這些項目是機器字組,即整數或指向物件的指標。使用者定義的物件可能會實例化一個陣列或 bytearray。主迴圈和 ISR 都可以變更這些內容,這是有效的做法。

當物件的結構被更改時就會產生風險,尤其是字典的情況。新增或刪除鍵可能觸發重新雜湊(rehash)。若硬中斷(hard ISR)在重新雜湊進行中執行,並嘗試存取某個項目,便可能發生當機。在內部,全域變數是以字典實作的。因此,主程式應在啟動會產生硬中斷的程序之前先建立所有必要的全域變數。應用程式碼也應避免刪除全域變數。

MicroPython 支援任意精度的整數。介於 230 -1 與 -230 之間的值會儲存在單一機器字組中。較大的值則以 Python 物件儲存。因此,對長整數的變更不可視為原子性的。在 ISR 中使用長整數並不安全,因為當變數的值改變時可能會嘗試配置記憶體。

克服浮點數的限制

一般而言,最好避免在 ISR 程式碼中使用浮點數:硬體裝置通常處理整數,而轉換為浮點數通常在主迴圈中完成。然而,有少數 DSP 演算法需要浮點數。在具備硬體浮點運算的平台上(例如以 STM32 為基礎的 OpenMV Cam),可使用內嵌的 ARM Thumb 組合語言來繞過此限制。這是因為處理器將浮點值儲存在一個機器字組中;這些值可透過一個浮點數陣列在 ISR 與主程式碼之間共享。

使用 micropython.schedule

此函式使 ISR 能夠排程一個回呼於「很快」執行。該回呼會被排入佇列,並在堆積未鎖定的時機執行。因此它可以建立 Python 物件並使用浮點數。該回呼也保證會在主程式已完成對任何 Python 物件的更新之後才執行,因此回呼不會遇到被部分更新的物件。

典型用法是處理感測器硬體。ISR 從硬體取得資料並使其能夠發出後續的中斷,接著排程一個回呼來處理該資料。

排程的回呼應遵循下文概述的中斷處理常式設計原則。這是為了避免因 I/O 活動以及共享資料的修改所造成的問題——任何會搶占主程式迴圈的程式碼都可能出現這類問題。

執行時間需要相對於中斷可能發生的頻率來考量。若在前一個回呼仍在執行時發生中斷,將會有另一個回呼實例被排入佇列等待執行;它會在目前的實例完成後執行。因此,持續的高中斷重複率會帶來佇列無限制成長並最終以 RuntimeError 失敗的風險。

若要傳遞給 schedule() 的回呼是繫結方法,請參考「Python 物件的建立」中的說明。

例外

若 ISR 拋出例外,它不會傳播到主迴圈。除非該例外由 ISR 程式碼處理,否則該中斷將被停用。

與 asyncio 的介接

當 ISR 執行時,它可能搶占 asyncio 排程器。若 ISR 執行 asyncio 操作,排程器的運作可能會被擾亂。無論中斷是硬中斷或軟中斷,此情況皆適用;若 ISR 透過 micropython.schedule 將執行交給另一個函式,此情況同樣適用。特別是,在 ISR 情境中建立或取消任務(task)是無效的。與 asyncio 互動的安全做法是實作一個協程(coroutine),並透過 asyncio.ThreadSafeFlag 進行同步。下列片段示範如何回應中斷而建立任務:

tsf = asyncio.ThreadSafeFlag()


def isr(_):  # Interrupt handler
    tsf.set()


async def foo():
    while True:
        await tsf.wait()
        asyncio.create_task(bar())

在此範例中,ISR 的執行與 foo() 的執行之間會有不定量的延遲。這是協作式排程(cooperative scheduling)固有的特性。最大延遲取決於應用程式與平台,但通常可達數十毫秒(ms)。

一般問題

這僅是對即時程式設計這個主題的簡要介紹。初學者應注意,即時程式中的設計錯誤可能導致特別難以診斷的故障。這是因為它們可能僅偶爾發生,且發生的間隔基本上是隨機的。將初始設計做對、並在問題出現之前先預想到它們,是至關重要的。中斷處理常式與主程式兩者都需要在設計時對下列問題有所體認。

中斷處理常式的設計

如上所述,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 將進來的資料儲存於一個 bytearray,接著將接收到的位元組數加到一個代表待處理位元組總數的整數上。主程式讀取該位元組數、處理這些位元組,然後將待處理的位元組數清除歸零。這在中斷恰好於主程式讀取位元組數之後立即發生之前都能正常運作。ISR 將新增的資料放入緩衝區並更新接收數,但主程式已經讀取了該數值,因此只處理原先接收到的資料。新到達的位元組就此遺失。

有多種方法可以避免此風險,最簡單的是使用環形緩衝區(circular buffer)。若無法使用本身具備執行緒安全性的結構,下文還會說明其他做法。

可重入性

若某個函式或方法在主程式與一個或多個 ISR 之間共享,或在多個 ISR 之間共享,便可能產生潛在風險。這裡的問題是,該函式本身可能被中斷,並執行該函式的另一個實例。若此情況會發生,該函式就必須設計為可重入的。如何做到這一點是超出本教學範圍的進階主題。

臨界區段

臨界區段程式碼的一個例子是會存取多個可能受 ISR 影響的變數的程式碼。若中斷恰好在對個別變數的存取之間發生,這些變數的值將會不一致。這是一種稱為競爭條件(race condition)的風險實例:ISR 與主程式迴圈競相更改這些變數。為避免不一致,必須採用某種手段以確保 ISR 在臨界區段期間不會更改這些值。達成此目的的一種方法是在區段開始前發出 machine.disable_irq(),並在結束時發出 machine.enable_irq()。以下是此做法的一個範例:

import machine
import micropython
import array
import random
import time

micropython.alloc_emergency_exception_buf(100)


class BoundsException(Exception):
    pass


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


def callback1(t):
    global data, index
    for x in range(5):
        data[index] = random.getrandbits(30)  # simulate input
        index += 1
        if index >= ARRAYSIZE:
            raise BoundsException('Array bounds exceeded')


tim = machine.Timer(-1, freq=100, callback=callback1, hard=True)

for loop in range(1000):
    if index > 0:
        irq_state = machine.disable_irq()  # Start of critical section
        for x in range(index):
            print(data[x])
        index = 0
        machine.enable_irq(irq_state)  # End of critical section
        print('loop {}'.format(loop))
    time.sleep_ms(1)

tim.deinit()

臨界區段可能僅由單一行程式碼與單一變數構成。考慮以下程式碼片段。

count = 0


def cb(): # An interrupt callback
    count += 1


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

此範例說明了一個微妙的錯誤來源。主迴圈中的 count += 1 這一行帶有一個稱為讀取-修改-寫入(read-modify-write)的特定競爭條件風險。這是即時系統中錯誤的典型成因。在主迴圈中,MicroPython 讀取 count 的值、對其加 1,然後寫回。在罕見的情況下,中斷在讀取之後、寫入之前發生。中斷修改了 count,但其變更在 ISR 返回時被主迴圈覆寫。在真實系統中,這可能導致罕見且不可預測的故障。

如上所述,若在主程式碼中修改某個 Python 內建型別的實例,而該實例又在 ISR 中被存取,則應格外小心。執行該修改的程式碼應視為臨界區段,以確保 ISR 執行時該實例處於有效狀態。

若有資料集在不同的 ISR 之間共享,需要格外小心。這裡的風險是,較高優先順序的中斷可能在較低優先順序者部分更新共享資料時發生。處理這種情況是超出本介紹範圍的進階主題,這裡僅指出下文所述的互斥鎖(mutex)物件有時可派上用場。

在臨界區段期間停用中斷是慣常且最簡單的做法,但它會停用所有中斷,而非僅停用那個有可能造成問題的中斷。長時間停用中斷一般而言是不可取的。在計時器中斷的情況下,它會為回呼發生的時間引入變異。在裝置中斷的情況下,它可能導致裝置服務得太晚,可能造成資料遺失或裝置硬體中的溢位(overrun)錯誤。如同 ISR,主程式碼中的臨界區段也應具有短暫且可預測的持續時間。

處理臨界區段、並能大幅縮短中斷被停用時間的一種方法,是使用一種稱為互斥鎖(mutex,源自互斥 mutual exclusion 的概念)的物件。主程式在執行臨界區段前鎖定互斥鎖,並在結束時解鎖。ISR 測試互斥鎖是否被鎖定。若已被鎖定,它便避開臨界區段並返回。設計上的挑戰在於定義當存取臨界變數被拒絕時 ISR 應做什麼。互斥鎖的一個簡單範例可在 此處 找到。請注意,互斥鎖程式碼確實會停用中斷,但僅在八條機器指令的期間內:此做法的好處是其他中斷幾乎不受影響。

中斷與 REPL

中斷處理常式(例如與計時器相關的那些)在程式終止後仍可能繼續執行。在你可能原以為觸發回呼的物件已超出作用域的情況下,這可能產生意料之外的結果。例如在 OpenMV Cam 上:

def bar():
    foo = machine.Timer(-1, freq=4, callback=lambda t: print('.', end=''), hard=True)

bar()

這會持續執行,直到計時器被明確停用,或以 Ctrl-D 重設開發板為止。